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