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