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