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