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