]> git.decadent.org.uk Git - dak.git/blob - dak/transitions.py
add automatic mode for cleanups
[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     to_dump = 0
398     to_remove = []
399     # Now look through all defined transitions
400     for trans in transitions:
401         t = transitions[trans]
402         source = t["source"]
403         expected = t["new"]
404
405         # Will be None if nothing is in testing.
406         current = database.get_suite_version(source, "testing")
407
408         print get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
409
410         if current == None:
411             # No package in testing
412             print "Transition source %s not in testing, transition still ongoing." % (source)
413         else:
414             compare = apt_pkg.VersionCompare(current, expected)
415             if compare < 0:
416                 # This is still valid, the current version in database is older than
417                 # the new version we wait for
418                 print "This transition is still ongoing, we currently have version %s" % (current)
419             else:
420                 print "REMOVE: This transition is over, the target package reached testing. REMOVE"
421                 print "%s wanted version: %s, has %s" % (source, expected, current)
422                 to_remove.append(trans)
423                 to_dump = 1
424         print "-------------------------------------------------------------------------"
425
426     if to_dump:
427         prompt = "Removing: "
428         for remove in to_remove:
429             prompt += remove
430             prompt += ","
431
432         prompt += " Commit Changes? (y/N)"
433         answer = ""
434
435         if Options["no-action"]:
436             answer="n"
437         elif Options["automatic"]:
438             answer="y"
439         else:
440             answer = utils.our_raw_input(prompt).lower()
441
442         if answer == "":
443             answer = "n"
444
445         if answer == 'n':
446             print "Not committing changes"
447             sys.exit(0)
448         elif answer == 'y':
449             print "Committing"
450             for remove in to_remove:
451                 del transitions[remove]
452
453             edit_file = temp_transitions_file(transitions)
454             write_transitions_from_file(edit_file)
455
456             print "Done"
457         else:
458             print "WTF are you typing?"
459             sys.exit(0)
460
461 ################################################################################
462
463 def get_info(trans, source, expected, rm, reason, packages):
464     """
465     Print information about a single transition.
466
467     @type trans: string
468     @param trans: Transition name
469
470     @type source: string
471     @param source: Source package
472
473     @type expected: string
474     @param expected: Expected version in testing
475
476     @type rm: string
477     @param rm: Responsible RM
478
479     @type reason: string
480     @param reason: Reason
481
482     @type packages: list
483     @param packages: list of blocked packages
484
485     """
486     return """Looking at transition: %s
487 Source:      %s
488 New Version: %s
489 Responsible: %s
490 Description: %s
491 Blocked Packages (total: %d): %s
492 """ % (trans, source, expected, rm, reason, len(packages), ", ".join(packages))
493
494 ################################################################################
495
496 def transition_info(transitions):
497     """
498     Print information about all defined transitions.
499     Calls L{get_info} for every transition and then tells user if the transition is
500     still ongoing or if the expected version already hit testing.
501
502     @type transitions: dict
503     @param transitions: defined transitions
504     """
505     for trans in transitions:
506         t = transitions[trans]
507         source = t["source"]
508         expected = t["new"]
509
510         # Will be None if nothing is in testing.
511         current = database.get_suite_version(source, "testing")
512
513         print get_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
514
515         if current == None:
516             # No package in testing
517             print "Transition source %s not in testing, transition still ongoing." % (source)
518         else:
519             compare = apt_pkg.VersionCompare(current, expected)
520             print "Apt compare says: %s" % (compare)
521             if compare < 0:
522                 # This is still valid, the current version in database is older than
523                 # the new version we wait for
524                 print "This transition is still ongoing, we currently have version %s" % (current)
525             else:
526                 print "This transition is over, the target package reached testing, should be removed"
527                 print "%s wanted version: %s, has %s" % (source, expected, current)
528         print "-------------------------------------------------------------------------"
529
530 ################################################################################
531
532 def main():
533     """
534     Prepare the work to be done, do basic checks.
535
536     @attention: This function may run B{within sudo}
537
538     """
539     global Cnf
540
541     #####################################
542     #### This can run within sudo !! ####
543     #####################################
544     init()
545
546     # Check if there is a file defined (and existant)
547     transpath = Cnf.get("Dinstall::Reject::ReleaseTransitions", "")
548     if transpath == "":
549         utils.warn("Dinstall::Reject::ReleaseTransitions not defined")
550         sys.exit(1)
551     if not os.path.exists(transpath):
552         utils.warn("ReleaseTransitions file, %s, not found." %
553                           (Cnf["Dinstall::Reject::ReleaseTransitions"]))
554         sys.exit(1)
555     # Also check if our temp directory is defined and existant
556     temppath = Cnf.get("Transitions::TempPath", "")
557     if temppath == "":
558         utils.warn("Transitions::TempPath not defined")
559         sys.exit(1)
560     if not os.path.exists(temppath):
561         utils.warn("Temporary path %s not found." %
562                           (Cnf["Transitions::TempPath"]))
563         sys.exit(1)
564
565     if Options["import"]:
566         try:
567             write_transitions_from_file(Options["import"])
568         except TransitionsError, m:
569             print m
570             sys.exit(2)
571         sys.exit(0)
572     ##############################################
573     #### Up to here it can run within sudo !! ####
574     ##############################################
575
576     # Parse the yaml file
577     transitions = load_transitions(transpath)
578     if transitions == None:
579         # Something very broken with the transitions, exit
580         utils.warn("Could not parse existing transitions file. Aborting.")
581         sys.exit(2)
582
583     if Options["edit"]:
584         # Let's edit the transitions file
585         edit_transitions()
586     elif Options["check"]:
587         # Check and remove outdated transitions
588         check_transitions(transitions)
589     else:
590         # Output information about the currently defined transitions.
591         print "Currently defined transitions:"
592         transition_info(transitions)
593
594     sys.exit(0)
595
596 ################################################################################
597
598 if __name__ == '__main__':
599     main()