]> git.decadent.org.uk Git - dak.git/blob - dak/transitions.py
Merge remote-tracking branch 'jcristau/cs-set-log-suite'
[dak.git] / dak / transitions.py
1 #!/usr/bin/env python
2
3 """
4 Display, edit and check the release manager's transition file.
5
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 """
10
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.
15
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.
20
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
24
25 ################################################################################
26
27 # <elmo> if klecker.d.o died, I swear to god, I'm going to migrate to gentoo.
28
29 ################################################################################
30
31 import os
32 import sys
33 import time
34 import errno
35 import fcntl
36 import tempfile
37 import apt_pkg
38
39 from daklib.dbconn import *
40 from daklib import utils
41 from daklib.dak_exceptions import TransitionsError
42 from daklib.regexes import re_broken_package
43 import yaml
44
45 # Globals
46 Cnf = None      #: Configuration, apt_pkg.Configuration
47 Options = None  #: Parsed CommandLine arguments
48
49 ################################################################################
50
51 #####################################
52 #### This may run within sudo !! ####
53 #####################################
54 def init():
55     """
56     Initialize. Sets up database connection, parses commandline arguments.
57
58     @attention: This function may run B{within sudo}
59
60     """
61     global Cnf, Options
62
63     apt_pkg.init()
64
65     Cnf = utils.get_conf()
66
67     Arguments = [('a',"automatic","Edit-Transitions::Options::Automatic"),
68                  ('h',"help","Edit-Transitions::Options::Help"),
69                  ('e',"edit","Edit-Transitions::Options::Edit"),
70                  ('i',"import","Edit-Transitions::Options::Import", "HasArg"),
71                  ('c',"check","Edit-Transitions::Options::Check"),
72                  ('s',"sudo","Edit-Transitions::Options::Sudo"),
73                  ('n',"no-action","Edit-Transitions::Options::No-Action")]
74
75     for i in ["automatic", "help", "no-action", "edit", "import", "check", "sudo"]:
76         if not Cnf.has_key("Edit-Transitions::Options::%s" % (i)):
77             Cnf["Edit-Transitions::Options::%s" % (i)] = ""
78
79     apt_pkg.parse_commandline(Cnf, Arguments, sys.argv)
80
81     Options = Cnf.subtree("Edit-Transitions::Options")
82
83     if Options["help"]:
84         usage()
85
86     username = utils.getusername()
87     if username != "dak":
88         print "Non-dak user: %s" % username
89         Options["sudo"] = "y"
90
91     # Initialise DB connection
92     DBConn()
93
94 ################################################################################
95
96 def usage (exit_code=0):
97     print """Usage: transitions [OPTION]...
98 Update and check the release managers transition file.
99
100 Options:
101
102   -h, --help                show this help and exit.
103   -e, --edit                edit the transitions file
104   -i, --import <file>       check and import transitions from file
105   -c, --check               check the transitions file, remove outdated entries
106   -S, --sudo                use sudo to update transitions file
107   -a, --automatic           don't prompt (only affects check).
108   -n, --no-action           don't do anything (only affects check)"""
109
110     sys.exit(exit_code)
111
112 ################################################################################
113
114 #####################################
115 #### This may run within sudo !! ####
116 #####################################
117 def load_transitions(trans_file):
118     """
119     Parse a transition yaml file and check it for validity.
120
121     @attention: This function may run B{within sudo}
122
123     @type trans_file: string
124     @param trans_file: filename to parse
125
126     @rtype: dict or None
127     @return: validated dictionary of transition entries or None
128              if validation fails, empty string if reading C{trans_file}
129              returned something else than a dict
130
131     """
132     # Parse the yaml file
133     sourcefile = file(trans_file, 'r')
134     sourcecontent = sourcefile.read()
135     failure = False
136     try:
137         trans = yaml.safe_load(sourcecontent)
138     except yaml.YAMLError as exc:
139         # Someone fucked it up
140         print "ERROR: %s" % (exc)
141         return None
142
143     # lets do further validation here
144     checkkeys = ["source", "reason", "packages", "new", "rm"]
145
146     # If we get an empty definition - we just have nothing to check, no transitions defined
147     if type(trans) != dict:
148         # This can be anything. We could have no transitions defined. Or someone totally fucked up the
149         # file, adding stuff in a way we dont know or want. Then we set it empty - and simply have no
150         # transitions anymore. User will see it in the information display after he quit the editor and
151         # could fix it
152         trans = ""
153         return trans
154
155     try:
156         for test in trans:
157             t = trans[test]
158
159             # First check if we know all the keys for the transition and if they have
160             # the right type (and for the packages also if the list has the right types
161             # included, ie. not a list in list, but only str in the list)
162             for key in t:
163                 if key not in checkkeys:
164                     print "ERROR: Unknown key %s in transition %s" % (key, test)
165                     failure = True
166
167                 if key == "packages":
168                     if type(t[key]) != list:
169                         print "ERROR: Unknown type %s for packages in transition %s." % (type(t[key]), test)
170                         failure = True
171                     try:
172                         for package in t["packages"]:
173                             if type(package) != str:
174                                 print "ERROR: Packages list contains invalid type %s (as %s) in transition %s" % (type(package), package, test)
175                                 failure = True
176                             if re_broken_package.match(package):
177                                 # Someone had a space too much (or not enough), we have something looking like
178                                 # "package1 - package2" now.
179                                 print "ERROR: Invalid indentation of package list in transition %s, around package(s): %s" % (test, package)
180                                 failure = True
181                     except TypeError:
182                         # In case someone has an empty packages list
183                         print "ERROR: No packages defined in transition %s" % (test)
184                         failure = True
185                         continue
186
187                 elif type(t[key]) != str:
188                     if key == "new" and type(t[key]) == int:
189                         # Ok, debian native version
190                         continue
191                     else:
192                         print "ERROR: Unknown type %s for key %s in transition %s" % (type(t[key]), key, test)
193                         failure = True
194
195             # And now the other way round - are all our keys defined?
196             for key in checkkeys:
197                 if key not in t:
198                     print "ERROR: Missing key %s in transition %s" % (key, test)
199                     failure = True
200     except TypeError:
201         # In case someone defined very broken things
202         print "ERROR: Unable to parse the file"
203         failure = True
204
205
206     if failure:
207         return None
208
209     return trans
210
211 ################################################################################
212
213 #####################################
214 #### This may run within sudo !! ####
215 #####################################
216 def lock_file(f):
217     """
218     Lock a file
219
220     @attention: This function may run B{within sudo}
221
222     """
223     for retry in range(10):
224         lock_fd = os.open(f, os.O_RDWR | os.O_CREAT)
225         try:
226             fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
227             return lock_fd
228         except OSError as e:
229             if errno.errorcode[e.errno] == 'EACCES' or errno.errorcode[e.errno] == 'EEXIST':
230                 print "Unable to get lock for %s (try %d of 10)" % \
231                         (file, retry+1)
232                 time.sleep(60)
233             else:
234                 raise
235
236     utils.fubar("Couldn't obtain lock for %s." % (f))
237
238 ################################################################################
239
240 #####################################
241 #### This may run within sudo !! ####
242 #####################################
243 def write_transitions(from_trans):
244     """
245     Update the active transitions file safely.
246     This function takes a parsed input file (which avoids invalid
247     files or files that may be be modified while the function is
248     active) and ensure the transitions file is updated atomically
249     to avoid locks.
250
251     @attention: This function may run B{within sudo}
252
253     @type from_trans: dict
254     @param from_trans: transitions dictionary, as returned by L{load_transitions}
255
256     """
257
258     trans_file = Cnf["Dinstall::ReleaseTransitions"]
259     trans_temp = trans_file + ".tmp"
260
261     trans_lock = lock_file(trans_file)
262     temp_lock  = lock_file(trans_temp)
263
264     destfile = file(trans_temp, 'w')
265     yaml.safe_dump(from_trans, destfile, default_flow_style=False)
266     destfile.close()
267
268     os.rename(trans_temp, trans_file)
269     os.close(temp_lock)
270     os.close(trans_lock)
271
272 ################################################################################
273
274 ##########################################
275 #### This usually runs within sudo !! ####
276 ##########################################
277 def write_transitions_from_file(from_file):
278     """
279     We have a file we think is valid; if we're using sudo, we invoke it
280     here, otherwise we just parse the file and call write_transitions
281
282     @attention: This function usually runs B{within sudo}
283
284     @type from_file: filename
285     @param from_file: filename of a transitions file
286
287     """
288
289     # Lets check if from_file is in the directory we expect it to be in
290     if not os.path.abspath(from_file).startswith(Cnf["Dir::TempPath"]):
291         print "Will not accept transitions file outside of %s" % (Cnf["Dir::TempPath"])
292         sys.exit(3)
293
294     if Options["sudo"]:
295         os.spawnl(os.P_WAIT, "/usr/bin/sudo", "/usr/bin/sudo", "-u", "dak", "-H",
296               "/usr/local/bin/dak", "transitions", "--import", from_file)
297     else:
298         trans = load_transitions(from_file)
299         if trans is None:
300             raise TransitionsError("Unparsable transitions file %s" % (file))
301         write_transitions(trans)
302
303 ################################################################################
304
305 def temp_transitions_file(transitions):
306     """
307     Open a temporary file and dump the current transitions into it, so users
308     can edit them.
309
310     @type transitions: dict
311     @param transitions: current defined transitions
312
313     @rtype: string
314     @return: path of newly created tempfile
315
316     @note: NB: file is unlinked by caller, but fd is never actually closed.
317            We need the chmod, as the file is (most possibly) copied from a
318            sudo-ed script and would be unreadable if it has default mkstemp mode
319     """
320
321     (fd, path) = tempfile.mkstemp("", "transitions", Cnf["Dir::TempPath"])
322     os.chmod(path, 0o644)
323     f = open(path, "w")
324     yaml.safe_dump(transitions, f, default_flow_style=False)
325     return path
326
327 ################################################################################
328
329 def edit_transitions():
330     """ Edit the defined transitions. """
331     trans_file = Cnf["Dinstall::ReleaseTransitions"]
332     edit_file = temp_transitions_file(load_transitions(trans_file))
333
334     editor = os.environ.get("EDITOR", "vi")
335
336     while True:
337         result = os.system("%s %s" % (editor, edit_file))
338         if result != 0:
339             os.unlink(edit_file)
340             utils.fubar("%s invocation failed for %s, not removing tempfile." % (editor, edit_file))
341
342         # Now try to load the new file
343         test = load_transitions(edit_file)
344
345         if test == None:
346             # Edit is broken
347             print "Edit was unparsable."
348             prompt = "[E]dit again, Drop changes?"
349             default = "E"
350         else:
351             print "Edit looks okay.\n"
352             print "The following transitions are defined:"
353             print "------------------------------------------------------------------------"
354             transition_info(test)
355
356             prompt = "[S]ave, Edit again, Drop changes?"
357             default = "S"
358
359         answer = "XXX"
360         while prompt.find(answer) == -1:
361             answer = utils.our_raw_input(prompt)
362             if answer == "":
363                 answer = default
364             answer = answer[:1].upper()
365
366         if answer == 'E':
367             continue
368         elif answer == 'D':
369             os.unlink(edit_file)
370             print "OK, discarding changes"
371             sys.exit(0)
372         elif answer == 'S':
373             # Ready to save
374             break
375         else:
376             print "You pressed something you shouldn't have :("
377             sys.exit(1)
378
379     # We seem to be done and also have a working file. Copy over.
380     write_transitions_from_file(edit_file)
381     os.unlink(edit_file)
382
383     print "Transitions file updated."
384
385 ################################################################################
386
387 def check_transitions(transitions):
388     """
389     Check if the defined transitions still apply and remove those that no longer do.
390     @note: Asks the user for confirmation first unless -a has been set.
391
392     """
393     global Cnf
394
395     to_dump = 0
396     to_remove = []
397     info = {}
398
399     session = DBConn().session()
400
401     # Now look through all defined transitions
402     for trans in transitions:
403         t = transitions[trans]
404         source = t["source"]
405         expected = t["new"]
406
407         # Will be an empty list if nothing is in testing.
408         sourceobj = get_source_in_suite(source, "testing", session)
409
410         info[trans] = get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
411         print info[trans]
412
413         if sourceobj is None:
414             # No package in testing
415             print "Transition source %s not in testing, transition still ongoing." % (source)
416         else:
417             current = sourceobj.version
418             compare = apt_pkg.version_compare(current, expected)
419             if compare < 0:
420                 # This is still valid, the current version in database is older than
421                 # the new version we wait for
422                 print "This transition is still ongoing, we currently have version %s" % (current)
423             else:
424                 print "REMOVE: This transition is over, the target package reached testing. REMOVE"
425                 print "%s wanted version: %s, has %s" % (source, expected, current)
426                 to_remove.append(trans)
427                 to_dump = 1
428         print "-------------------------------------------------------------------------"
429
430     if to_dump:
431         prompt = "Removing: "
432         for remove in to_remove:
433             prompt += remove
434             prompt += ","
435
436         prompt += " Commit Changes? (y/N)"
437         answer = ""
438
439         if Options["no-action"]:
440             answer="n"
441         elif Options["automatic"]:
442             answer="y"
443         else:
444             answer = utils.our_raw_input(prompt).lower()
445
446         if answer == "":
447             answer = "n"
448
449         if answer == 'n':
450             print "Not committing changes"
451             sys.exit(0)
452         elif answer == 'y':
453             print "Committing"
454             subst = {}
455             subst['__SUBJECT__'] = "Transitions completed: " + ", ".join(sorted(to_remove))
456             subst['__TRANSITION_MESSAGE__'] = "The following transitions were removed:\n"
457             for remove in sorted(to_remove):
458                 subst['__TRANSITION_MESSAGE__'] += info[remove] + '\n'
459                 del transitions[remove]
460
461             # If we have a mail address configured for transitions,
462             # send a notification
463             subst['__TRANSITION_EMAIL__'] = Cnf.get("Transitions::Notifications", "")
464             if subst['__TRANSITION_EMAIL__'] != "":
465                 print "Sending notification to %s" % subst['__TRANSITION_EMAIL__']
466                 subst['__DAK_ADDRESS__'] = Cnf["Dinstall::MyEmailAddress"]
467                 subst['__BCC__'] = 'X-DAK: dak transitions'
468                 if Cnf.has_key("Dinstall::Bcc"):
469                     subst["__BCC__"] += '\nBcc: %s' % Cnf["Dinstall::Bcc"]
470                 message = utils.TemplateSubst(subst,
471                                               os.path.join(Cnf["Dir::Templates"], 'transition.removed'))
472                 utils.send_mail(message)
473
474             edit_file = temp_transitions_file(transitions)
475             write_transitions_from_file(edit_file)
476
477             print "Done"
478         else:
479             print "WTF are you typing?"
480             sys.exit(0)
481
482 ################################################################################
483
484 def get_info(trans, source, expected, rm, reason, packages):
485     """
486     Print information about a single transition.
487
488     @type trans: string
489     @param trans: Transition name
490
491     @type source: string
492     @param source: Source package
493
494     @type expected: string
495     @param expected: Expected version in testing
496
497     @type rm: string
498     @param rm: Responsible RM
499
500     @type reason: string
501     @param reason: Reason
502
503     @type packages: list
504     @param packages: list of blocked packages
505
506     """
507     return """Looking at transition: %s
508 Source:      %s
509 New Version: %s
510 Responsible: %s
511 Description: %s
512 Blocked Packages (total: %d): %s
513 """ % (trans, source, expected, rm, reason, len(packages), ", ".join(packages))
514
515 ################################################################################
516
517 def transition_info(transitions):
518     """
519     Print information about all defined transitions.
520     Calls L{get_info} for every transition and then tells user if the transition is
521     still ongoing or if the expected version already hit testing.
522
523     @type transitions: dict
524     @param transitions: defined transitions
525     """
526
527     session = DBConn().session()
528
529     for trans in transitions:
530         t = transitions[trans]
531         source = t["source"]
532         expected = t["new"]
533
534         # Will be None if nothing is in testing.
535         sourceobj = get_source_in_suite(source, "testing", session)
536
537         print get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
538
539         if sourceobj is None:
540             # No package in testing
541             print "Transition source %s not in testing, transition still ongoing." % (source)
542         else:
543             compare = apt_pkg.version_compare(sourceobj.version, expected)
544             print "Apt compare says: %s" % (compare)
545             if compare < 0:
546                 # This is still valid, the current version in database is older than
547                 # the new version we wait for
548                 print "This transition is still ongoing, we currently have version %s" % (sourceobj.version)
549             else:
550                 print "This transition is over, the target package reached testing, should be removed"
551                 print "%s wanted version: %s, has %s" % (source, expected, sourceobj.version)
552         print "-------------------------------------------------------------------------"
553
554 ################################################################################
555
556 def main():
557     """
558     Prepare the work to be done, do basic checks.
559
560     @attention: This function may run B{within sudo}
561
562     """
563     global Cnf
564
565     #####################################
566     #### This can run within sudo !! ####
567     #####################################
568     init()
569
570     # Check if there is a file defined (and existant)
571     transpath = Cnf.get("Dinstall::ReleaseTransitions", "")
572     if transpath == "":
573         utils.warn("Dinstall::ReleaseTransitions not defined")
574         sys.exit(1)
575     if not os.path.exists(transpath):
576         utils.warn("ReleaseTransitions file, %s, not found." %
577                           (Cnf["Dinstall::ReleaseTransitions"]))
578         sys.exit(1)
579     # Also check if our temp directory is defined and existant
580     temppath = Cnf.get("Dir::TempPath", "")
581     if temppath == "":
582         utils.warn("Dir::TempPath not defined")
583         sys.exit(1)
584     if not os.path.exists(temppath):
585         utils.warn("Temporary path %s not found." %
586                           (Cnf["Dir::TempPath"]))
587         sys.exit(1)
588
589     if Options["import"]:
590         try:
591             write_transitions_from_file(Options["import"])
592         except TransitionsError as m:
593             print m
594             sys.exit(2)
595         sys.exit(0)
596     ##############################################
597     #### Up to here it can run within sudo !! ####
598     ##############################################
599
600     # Parse the yaml file
601     transitions = load_transitions(transpath)
602     if transitions == None:
603         # Something very broken with the transitions, exit
604         utils.warn("Could not parse existing transitions file. Aborting.")
605         sys.exit(2)
606
607     if Options["edit"]:
608         # Let's edit the transitions file
609         edit_transitions()
610     elif Options["check"]:
611         # Check and remove outdated transitions
612         check_transitions(transitions)
613     else:
614         # Output information about the currently defined transitions.
615         print "Currently defined transitions:"
616         transition_info(transitions)
617
618     sys.exit(0)
619
620 ################################################################################
621
622 if __name__ == '__main__':
623     main()