1#! /usr/bin/env python3
3"""
4Display, edit and check the release manager's transition file.
6@contact: Debian FTP Master <ftpmaster@debian.org>
7@copyright: 2008 Joerg Jaspert <joerg@debian.org>
8@license: GNU General Public License version 2 or later
9"""
11# This program is free software; you can redistribute it and/or modify
12# it under the terms of the GNU General Public License as published by
13# the Free Software Foundation; either version 2 of the License, or
14# (at your option) any later version.
16# This program is distributed in the hope that it will be useful,
17# but WITHOUT ANY WARRANTY; without even the implied warranty of
18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19# GNU General Public License for more details.
21# You should have received a copy of the GNU General Public License
22# along with this program; if not, write to the Free Software
23# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
25################################################################################
27# <elmo> if klecker.d.o died, I swear to god, I'm going to migrate to gentoo.
29################################################################################
31import errno
32import fcntl
33import os
34import subprocess
35import sys
36import tempfile
37import time
38from collections.abc import Iterable
39from typing import Optional
41import apt_pkg
42import yaml
44from daklib import utils
45from daklib.dak_exceptions import TransitionsError
46from daklib.dbconn import DBConn, get_source_in_suite
47from daklib.regexes import re_broken_package
49# Globals
50Cnf = None #: Configuration, apt_pkg.Configuration
51Options = None #: Parsed CommandLine arguments
53################################################################################
55#####################################
56#### This may run within sudo !! ####
57#####################################
60def init():
61 """
62 Initialize. Sets up database connection, parses commandline arguments.
64 .. warning::
66 This function may run **within sudo**
68 """
69 global Cnf, Options
71 apt_pkg.init()
73 Cnf = utils.get_conf()
75 Arguments = [
76 ("a", "automatic", "Edit-Transitions::Options::Automatic"),
77 ("h", "help", "Edit-Transitions::Options::Help"),
78 ("e", "edit", "Edit-Transitions::Options::Edit"),
79 ("i", "import", "Edit-Transitions::Options::Import", "HasArg"),
80 ("c", "check", "Edit-Transitions::Options::Check"),
81 ("s", "sudo", "Edit-Transitions::Options::Sudo"),
82 ("n", "no-action", "Edit-Transitions::Options::No-Action"),
83 ]
85 for i in ["automatic", "help", "no-action", "edit", "import", "check", "sudo"]:
86 key = "Edit-Transitions::Options::%s" % i
87 if key not in Cnf: 87 ↛ 85line 87 didn't jump to line 85, because the condition on line 87 was never false
88 Cnf[key] = ""
90 apt_pkg.parse_commandline(Cnf, Arguments, sys.argv)
92 Options = Cnf.subtree("Edit-Transitions::Options")
94 if Options["help"]: 94 ↛ 97line 94 didn't jump to line 97, because the condition on line 94 was never false
95 usage()
97 username = utils.getusername()
98 if username != "dak":
99 print("Non-dak user: %s" % username)
100 Options["sudo"] = "y"
102 # Initialise DB connection
103 DBConn()
106################################################################################
109def usage(exit_code=0):
110 print(
111 """Usage: transitions [OPTION]...
112Update and check the release managers transition file.
114Options:
116 -h, --help show this help and exit.
117 -e, --edit edit the transitions file
118 -i, --import <file> check and import transitions from file
119 -c, --check check the transitions file, remove outdated entries
120 -S, --sudo use sudo to update transitions file
121 -a, --automatic don't prompt (only affects check).
122 -n, --no-action don't do anything (only affects check)"""
123 )
125 sys.exit(exit_code)
128################################################################################
130#####################################
131#### This may run within sudo !! ####
132#####################################
135def load_transitions(trans_file: str) -> Optional[dict]:
136 """
137 Parse a transition yaml file and check it for validity.
139 .. warning::
141 This function may run **within sudo**
143 :param trans_file: filename to parse
144 :return: validated dictionary of transition entries or None
145 if validation fails, empty string if reading `trans_file`
146 returned something else than a dict
148 """
149 # Parse the yaml file
150 with open(trans_file, "r") as sourcefile:
151 sourcecontent = sourcefile.read()
152 failure = False
153 try:
154 trans = yaml.safe_load(sourcecontent)
155 except yaml.YAMLError as exc:
156 # Someone fucked it up
157 print("ERROR: %s" % (exc))
158 return None
160 # lets do further validation here
161 checkkeys = ["source", "reason", "packages", "new", "rm"]
163 # If we get an empty definition - we just have nothing to check, no transitions defined
164 if not isinstance(trans, dict):
165 # This can be anything. We could have no transitions defined. Or someone totally fucked up the
166 # file, adding stuff in a way we dont know or want. Then we set it empty - and simply have no
167 # transitions anymore. User will see it in the information display after he quit the editor and
168 # could fix it
169 trans = ""
170 return trans
172 try:
173 for test in trans:
174 t = trans[test]
176 # First check if we know all the keys for the transition and if they have
177 # the right type (and for the packages also if the list has the right types
178 # included, ie. not a list in list, but only str in the list)
179 for key in t:
180 if key not in checkkeys:
181 print("ERROR: Unknown key %s in transition %s" % (key, test))
182 failure = True
184 if key == "packages":
185 if not isinstance(t[key], list):
186 print(
187 "ERROR: Unknown type %s for packages in transition %s."
188 % (type(t[key]), test)
189 )
190 failure = True
191 try:
192 for package in t["packages"]:
193 if not isinstance(package, str):
194 print(
195 "ERROR: Packages list contains invalid type %s (as %s) in transition %s"
196 % (type(package), package, test)
197 )
198 failure = True
199 if re_broken_package.match(package):
200 # Someone had a space too much (or not enough), we have something looking like
201 # "package1 - package2" now.
202 print(
203 "ERROR: Invalid indentation of package list in transition %s, around package(s): %s"
204 % (test, package)
205 )
206 failure = True
207 except TypeError:
208 # In case someone has an empty packages list
209 print("ERROR: No packages defined in transition %s" % (test))
210 failure = True
211 continue
213 elif not isinstance(t[key], str):
214 if key == "new" and isinstance(t[key], int):
215 # Ok, debian native version
216 continue
217 else:
218 print(
219 "ERROR: Unknown type %s for key %s in transition %s"
220 % (type(t[key]), key, test)
221 )
222 failure = True
224 # And now the other way round - are all our keys defined?
225 for key in checkkeys:
226 if key not in t:
227 print("ERROR: Missing key %s in transition %s" % (key, test))
228 failure = True
229 except TypeError:
230 # In case someone defined very broken things
231 print("ERROR: Unable to parse the file")
232 failure = True
234 if failure:
235 return None
237 return trans
240################################################################################
242#####################################
243#### This may run within sudo !! ####
244#####################################
247def lock_file(f):
248 """
249 Lock a file
251 .. warning::
253 This function may run **within sudo**
254 """
255 for retry in range(10):
256 lock_fd = os.open(f, os.O_RDWR | os.O_CREAT)
257 try:
258 fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
259 return lock_fd
260 except OSError as e:
261 if e.errno in (errno.EACCES, errno.EEXIST):
262 print("Unable to get lock for %s (try %d of 10)" % (f, retry + 1))
263 time.sleep(60)
264 else:
265 raise
267 utils.fubar("Couldn't obtain lock for %s." % (f))
270################################################################################
272#####################################
273#### This may run within sudo !! ####
274#####################################
277def write_transitions(from_trans: dict) -> None:
278 """
279 Update the active transitions file safely.
280 This function takes a parsed input file (which avoids invalid
281 files or files that may be be modified while the function is
282 active) and ensure the transitions file is updated atomically
283 to avoid locks.
285 .. warning::
287 This function may run **within sudo**
289 :param from_trans: transitions dictionary, as returned by :func:`load_transitions`
290 """
292 trans_file = Cnf["Dinstall::ReleaseTransitions"]
293 trans_temp = trans_file + ".tmp"
295 trans_lock = lock_file(trans_file)
296 temp_lock = lock_file(trans_temp)
298 with open(trans_temp, "w") as destfile:
299 yaml.safe_dump(from_trans, destfile, default_flow_style=False)
301 os.rename(trans_temp, trans_file)
302 os.close(temp_lock)
303 os.close(trans_lock)
306################################################################################
308##########################################
309#### This usually runs within sudo !! ####
310##########################################
313def write_transitions_from_file(from_file: str) -> None:
314 """
315 We have a file we think is valid; if we're using sudo, we invoke it
316 here, otherwise we just parse the file and call write_transitions
318 .. warning::
320 This function usually runs **within sudo**
322 :param from_file: filename of a transitions file
323 """
325 # Lets check if from_file is in the directory we expect it to be in
326 if not os.path.abspath(from_file).startswith(Cnf["Dir::TempPath"]):
327 print("Will not accept transitions file outside of %s" % (Cnf["Dir::TempPath"]))
328 sys.exit(3)
330 if Options["sudo"]:
331 subprocess.check_call(
332 [
333 "/usr/bin/sudo",
334 "-u",
335 "dak",
336 "-H",
337 "/usr/local/bin/dak",
338 "transitions",
339 "--import",
340 from_file,
341 ]
342 )
343 else:
344 trans = load_transitions(from_file)
345 if trans is None:
346 raise TransitionsError("Unparsable transitions file %s" % (from_file))
347 write_transitions(trans)
350################################################################################
353def temp_transitions_file(transitions: dict) -> str:
354 """
355 Open a temporary file and dump the current transitions into it, so users
356 can edit them.
358 :param transitions: current defined transitions
359 :return: path of newly created tempfile
361 .. note::
363 file is unlinked by caller, but fd is never actually closed.
364 We need the chmod, as the file is (most possibly) copied from a
365 sudo-ed script and would be unreadable if it has default mkstemp mode
366 """
368 (fd, path) = tempfile.mkstemp("", "transitions", Cnf["Dir::TempPath"])
369 os.chmod(path, 0o644)
370 with open(path, "w") as f:
371 yaml.safe_dump(transitions, f, default_flow_style=False)
372 return path
375################################################################################
378def edit_transitions():
379 """Edit the defined transitions."""
380 trans_file = Cnf["Dinstall::ReleaseTransitions"]
381 edit_file = temp_transitions_file(load_transitions(trans_file))
383 editor = os.environ.get("EDITOR", "vi")
385 while True:
386 result = os.system("%s %s" % (editor, edit_file))
387 if result != 0:
388 os.unlink(edit_file)
389 utils.fubar(
390 "%s invocation failed for %s, not removing tempfile."
391 % (editor, edit_file)
392 )
394 # Now try to load the new file
395 test = load_transitions(edit_file)
397 if test is None:
398 # Edit is broken
399 print("Edit was unparsable.")
400 prompt = "[E]dit again, Drop changes?"
401 default = "E"
402 else:
403 print("Edit looks okay.\n")
404 print("The following transitions are defined:")
405 print(
406 "------------------------------------------------------------------------"
407 )
408 transition_info(test)
410 prompt = "[S]ave, Edit again, Drop changes?"
411 default = "S"
413 answer = "XXX"
414 while prompt.find(answer) == -1:
415 answer = utils.input_or_exit(prompt)
416 if answer == "":
417 answer = default
418 answer = answer[:1].upper()
420 if answer == "E":
421 continue
422 elif answer == "D":
423 os.unlink(edit_file)
424 print("OK, discarding changes")
425 sys.exit(0)
426 elif answer == "S":
427 # Ready to save
428 break
429 else:
430 print("You pressed something you shouldn't have :(")
431 sys.exit(1)
433 # We seem to be done and also have a working file. Copy over.
434 write_transitions_from_file(edit_file)
435 os.unlink(edit_file)
437 print("Transitions file updated.")
440################################################################################
443def check_transitions(transitions):
444 """
445 Check if the defined transitions still apply and remove those that no longer do.
446 @note: Asks the user for confirmation first unless -a has been set.
448 """
449 global Cnf
451 to_dump = 0
452 to_remove = []
453 info = {}
455 session = DBConn().session()
457 # Now look through all defined transitions
458 for trans in transitions:
459 t = transitions[trans]
460 source = t["source"]
461 expected = t["new"]
463 # Will be an empty list if nothing is in testing.
464 sourceobj = get_source_in_suite(source, "testing", session)
466 info[trans] = get_info(
467 trans, source, expected, t["rm"], t["reason"], t["packages"]
468 )
469 print(info[trans])
471 if sourceobj is None:
472 # No package in testing
473 print(
474 "Transition source %s not in testing, transition still ongoing."
475 % (source)
476 )
477 else:
478 current = sourceobj.version
479 compare = apt_pkg.version_compare(current, expected)
480 if compare < 0:
481 # This is still valid, the current version in database is older than
482 # the new version we wait for
483 print(
484 "This transition is still ongoing, we currently have version %s"
485 % (current)
486 )
487 else:
488 print(
489 "REMOVE: This transition is over, the target package reached testing. REMOVE"
490 )
491 print("%s wanted version: %s, has %s" % (source, expected, current))
492 to_remove.append(trans)
493 to_dump = 1
494 print(
495 "-------------------------------------------------------------------------"
496 )
498 if to_dump:
499 prompt = "Removing: "
500 for remove in to_remove:
501 prompt += remove
502 prompt += ","
504 prompt += " Commit Changes? (y/N)"
505 answer = ""
507 if Options["no-action"]:
508 answer = "n"
509 elif Options["automatic"]:
510 answer = "y"
511 else:
512 answer = utils.input_or_exit(prompt).lower()
514 if answer == "":
515 answer = "n"
517 if answer == "n":
518 print("Not committing changes")
519 sys.exit(0)
520 elif answer == "y":
521 print("Committing")
522 subst = {}
523 subst["__SUBJECT__"] = "Transitions completed: " + ", ".join(
524 sorted(to_remove)
525 )
526 subst["__TRANSITION_MESSAGE__"] = (
527 "The following transitions were removed:\n"
528 )
529 for remove in sorted(to_remove):
530 subst["__TRANSITION_MESSAGE__"] += info[remove] + "\n"
531 del transitions[remove]
533 # If we have a mail address configured for transitions,
534 # send a notification
535 subst["__TRANSITION_EMAIL__"] = Cnf.get("Transitions::Notifications", "")
536 if subst["__TRANSITION_EMAIL__"] != "":
537 print("Sending notification to %s" % subst["__TRANSITION_EMAIL__"])
538 subst["__DAK_ADDRESS__"] = Cnf["Dinstall::MyEmailAddress"]
539 subst["__BCC__"] = "X-DAK: dak transitions"
540 if "Dinstall::Bcc" in Cnf:
541 subst["__BCC__"] += "\nBcc: %s" % Cnf["Dinstall::Bcc"]
542 message = utils.TemplateSubst(
543 subst, os.path.join(Cnf["Dir::Templates"], "transition.removed")
544 )
545 utils.send_mail(message)
547 edit_file = temp_transitions_file(transitions)
548 write_transitions_from_file(edit_file)
550 print("Done")
551 else:
552 print("WTF are you typing?")
553 sys.exit(0)
556################################################################################
559def get_info(
560 trans: str,
561 source: str,
562 expected: str,
563 rm: str,
564 reason: str,
565 packages: Iterable[str],
566) -> str:
567 """
568 Print information about a single transition.
570 :param trans: Transition name
571 :param source: Source package
572 :param expected: Expected version in testing
573 :param rm: Responsible release manager (RM)
574 :param reason: Reason
575 :param packages: list of blocked packages
576 """
577 return """Looking at transition: %s
578Source: %s
579New Version: %s
580Responsible: %s
581Description: %s
582Blocked Packages (total: %d): %s
583""" % (
584 trans,
585 source,
586 expected,
587 rm,
588 reason,
589 len(packages),
590 ", ".join(packages),
591 )
594################################################################################
597def transition_info(transitions: dict):
598 """
599 Print information about all defined transitions.
600 Calls :func:`get_info` for every transition and then tells user if the transition is
601 still ongoing or if the expected version already hit testing.
603 :param transitions: defined transitions
604 """
606 session = DBConn().session()
608 for trans in transitions:
609 t = transitions[trans]
610 source = t["source"]
611 expected = t["new"]
613 # Will be None if nothing is in testing.
614 sourceobj = get_source_in_suite(source, "testing", session)
616 print(get_info(trans, source, expected, t["rm"], t["reason"], t["packages"]))
618 if sourceobj is None:
619 # No package in testing
620 print(
621 "Transition source %s not in testing, transition still ongoing."
622 % (source)
623 )
624 else:
625 compare = apt_pkg.version_compare(sourceobj.version, expected)
626 print("Apt compare says: %s" % (compare))
627 if compare < 0:
628 # This is still valid, the current version in database is older than
629 # the new version we wait for
630 print(
631 "This transition is still ongoing, we currently have version %s"
632 % (sourceobj.version)
633 )
634 else:
635 print(
636 "This transition is over, the target package reached testing, should be removed"
637 )
638 print(
639 "%s wanted version: %s, has %s"
640 % (source, expected, sourceobj.version)
641 )
642 print(
643 "-------------------------------------------------------------------------"
644 )
647################################################################################
650def main():
651 """
652 Prepare the work to be done, do basic checks.
654 .. warning::
656 This function may run **within sudo**
657 """
658 global Cnf
660 #####################################
661 #### This can run within sudo !! ####
662 #####################################
663 init()
665 # Check if there is a file defined (and existant)
666 transpath = Cnf.get("Dinstall::ReleaseTransitions", "")
667 if transpath == "":
668 utils.warn("Dinstall::ReleaseTransitions not defined")
669 sys.exit(1)
670 if not os.path.exists(transpath):
671 utils.warn(
672 "ReleaseTransitions file, %s, not found."
673 % (Cnf["Dinstall::ReleaseTransitions"])
674 )
675 sys.exit(1)
676 # Also check if our temp directory is defined and existant
677 temppath = Cnf.get("Dir::TempPath", "")
678 if temppath == "":
679 utils.warn("Dir::TempPath not defined")
680 sys.exit(1)
681 if not os.path.exists(temppath):
682 utils.warn("Temporary path %s not found." % (Cnf["Dir::TempPath"]))
683 sys.exit(1)
685 if Options["import"]:
686 try:
687 write_transitions_from_file(Options["import"])
688 except TransitionsError as m:
689 print(m)
690 sys.exit(2)
691 sys.exit(0)
692 ##############################################
693 #### Up to here it can run within sudo !! ####
694 ##############################################
696 # Parse the yaml file
697 transitions = load_transitions(transpath)
698 if transitions is None:
699 # Something very broken with the transitions, exit
700 utils.warn("Could not parse existing transitions file. Aborting.")
701 sys.exit(2)
703 if Options["edit"]:
704 # Let's edit the transitions file
705 edit_transitions()
706 elif Options["check"]:
707 # Check and remove outdated transitions
708 check_transitions(transitions)
709 else:
710 # Output information about the currently defined transitions.
711 print("Currently defined transitions:")
712 transition_info(transitions)
714 sys.exit(0)
717################################################################################
720if __name__ == "__main__":
721 main()