]> git.decadent.org.uk Git - dak.git/blob - dak/transitions.py
Merge commit 'buxy/bugfixes' 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['__TRANSITION_MESSAGE__'] = "The following transitions were removed:\n"
456             for remove in to_remove:
457                 subst['__TRANSITION_MESSAGE__'] += info[remove] + '\n'
458                 del transitions[remove]
459
460             # If we have a mail address configured for transitions,
461             # send a notification
462             subst['__TRANSITION_EMAIL__'] = Cnf.get("Transitions::Notifications", "")
463             if subst['__TRANSITION_EMAIL__'] != "":
464                 print "Sending notification to %s" % subst['__TRANSITION_EMAIL__']
465                 subst['__DAK_ADDRESS__'] = Cnf["Dinstall::MyEmailAddress"]
466                 subst['__BCC__'] = 'X-DAK: dak transitions'
467                 if Cnf.has_key("Dinstall::Bcc"):
468                     subst["__BCC__"] += '\nBcc: %s' % Cnf["Dinstall::Bcc"]
469                 message = utils.TemplateSubst(subst,
470                                               os.path.join(Cnf["Dir::Templates"], 'transition.removed'))
471                 utils.send_mail(message)
472
473             edit_file = temp_transitions_file(transitions)
474             write_transitions_from_file(edit_file)
475
476             print "Done"
477         else:
478             print "WTF are you typing?"
479             sys.exit(0)
480
481 ################################################################################
482
483 def get_info(trans, source, expected, rm, reason, packages):
484     """
485     Print information about a single transition.
486
487     @type trans: string
488     @param trans: Transition name
489
490     @type source: string
491     @param source: Source package
492
493     @type expected: string
494     @param expected: Expected version in testing
495
496     @type rm: string
497     @param rm: Responsible RM
498
499     @type reason: string
500     @param reason: Reason
501
502     @type packages: list
503     @param packages: list of blocked packages
504
505     """
506     return """Looking at transition: %s
507 Source:      %s
508 New Version: %s
509 Responsible: %s
510 Description: %s
511 Blocked Packages (total: %d): %s
512 """ % (trans, source, expected, rm, reason, len(packages), ", ".join(packages))
513
514 ################################################################################
515
516 def transition_info(transitions):
517     """
518     Print information about all defined transitions.
519     Calls L{get_info} for every transition and then tells user if the transition is
520     still ongoing or if the expected version already hit testing.
521
522     @type transitions: dict
523     @param transitions: defined transitions
524     """
525     for trans in transitions:
526         t = transitions[trans]
527         source = t["source"]
528         expected = t["new"]
529
530         # Will be None if nothing is in testing.
531         current = database.get_suite_version(source, "testing")
532
533         print get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
534
535         if current == None:
536             # No package in testing
537             print "Transition source %s not in testing, transition still ongoing." % (source)
538         else:
539             compare = apt_pkg.VersionCompare(current, expected)
540             print "Apt compare says: %s" % (compare)
541             if compare < 0:
542                 # This is still valid, the current version in database is older than
543                 # the new version we wait for
544                 print "This transition is still ongoing, we currently have version %s" % (current)
545             else:
546                 print "This transition is over, the target package reached testing, should be removed"
547                 print "%s wanted version: %s, has %s" % (source, expected, current)
548         print "-------------------------------------------------------------------------"
549
550 ################################################################################
551
552 def main():
553     """
554     Prepare the work to be done, do basic checks.
555
556     @attention: This function may run B{within sudo}
557
558     """
559     global Cnf
560
561     #####################################
562     #### This can run within sudo !! ####
563     #####################################
564     init()
565
566     # Check if there is a file defined (and existant)
567     transpath = Cnf.get("Dinstall::Reject::ReleaseTransitions", "")
568     if transpath == "":
569         utils.warn("Dinstall::Reject::ReleaseTransitions not defined")
570         sys.exit(1)
571     if not os.path.exists(transpath):
572         utils.warn("ReleaseTransitions file, %s, not found." %
573                           (Cnf["Dinstall::Reject::ReleaseTransitions"]))
574         sys.exit(1)
575     # Also check if our temp directory is defined and existant
576     temppath = Cnf.get("Transitions::TempPath", "")
577     if temppath == "":
578         utils.warn("Transitions::TempPath not defined")
579         sys.exit(1)
580     if not os.path.exists(temppath):
581         utils.warn("Temporary path %s not found." %
582                           (Cnf["Transitions::TempPath"]))
583         sys.exit(1)
584
585     if Options["import"]:
586         try:
587             write_transitions_from_file(Options["import"])
588         except TransitionsError, m:
589             print m
590             sys.exit(2)
591         sys.exit(0)
592     ##############################################
593     #### Up to here it can run within sudo !! ####
594     ##############################################
595
596     # Parse the yaml file
597     transitions = load_transitions(transpath)
598     if transitions == None:
599         # Something very broken with the transitions, exit
600         utils.warn("Could not parse existing transitions file. Aborting.")
601         sys.exit(2)
602
603     if Options["edit"]:
604         # Let's edit the transitions file
605         edit_transitions()
606     elif Options["check"]:
607         # Check and remove outdated transitions
608         check_transitions(transitions)
609     else:
610         # Output information about the currently defined transitions.
611         print "Currently defined transitions:"
612         transition_info(transitions)
613
614     sys.exit(0)
615
616 ################################################################################
617
618 if __name__ == '__main__':
619     main()