]> git.decadent.org.uk Git - dak.git/blob - dak/transitions.py
Use dak's facility for sending email. Add bts-categorize to cron.dinstall
[dak.git] / dak / transitions.py
1 #!/usr/bin/env python
2
3 # Display, edit and check the release manager's transition file.
4 # Copyright (C) 2008 Joerg Jaspert <joerg@debian.org>
5
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19
20 ################################################################################
21
22 # <elmo> if klecker.d.o died, I swear to god, I'm going to migrate to gentoo.
23
24 ################################################################################
25
26 import os, pg, sys, time, errno, fcntl, tempfile, pwd, re
27 import apt_pkg
28 from daklib import database
29 from daklib import utils
30 from daklib.dak_exceptions import TransitionsError
31 import yaml
32
33 # Globals
34 Cnf = None
35 Options = None
36 projectB = None
37
38 re_broken_package = re.compile(r"[a-zA-Z]\w+\s+\-.*")
39
40 ################################################################################
41
42 #####################################
43 #### This may run within sudo !! ####
44 #####################################
45 def init():
46     global Cnf, Options, projectB
47
48     apt_pkg.init()
49
50     Cnf = utils.get_conf()
51
52     Arguments = [('h',"help","Edit-Transitions::Options::Help"),
53                  ('e',"edit","Edit-Transitions::Options::Edit"),
54                  ('i',"import","Edit-Transitions::Options::Import", "HasArg"),
55                  ('c',"check","Edit-Transitions::Options::Check"),
56                  ('s',"sudo","Edit-Transitions::Options::Sudo"),
57                  ('n',"no-action","Edit-Transitions::Options::No-Action")]
58
59     for i in ["help", "no-action", "edit", "import", "check", "sudo"]:
60         if not Cnf.has_key("Edit-Transitions::Options::%s" % (i)):
61             Cnf["Edit-Transitions::Options::%s" % (i)] = ""
62
63     apt_pkg.ParseCommandLine(Cnf, Arguments, sys.argv)
64
65     Options = Cnf.SubTree("Edit-Transitions::Options")
66
67     if Options["help"]:
68         usage()
69
70     whoami = os.getuid()
71     whoamifull = pwd.getpwuid(whoami)
72     username = whoamifull[0]
73     if username != "dak":
74         print "Non-dak user: %s" % username
75         Options["sudo"] = "y"
76
77     projectB = pg.connect(Cnf["DB::Name"], Cnf["DB::Host"], int(Cnf["DB::Port"]))
78     database.init(Cnf, projectB)
79
80 ################################################################################
81
82 def usage (exit_code=0):
83     print """Usage: transitions [OPTION]...
84 Update and check the release managers transition file.
85
86 Options:
87
88   -h, --help                show this help and exit.
89   -e, --edit                edit the transitions file
90   -i, --import <file>       check and import transitions from file
91   -c, --check               check the transitions file, remove outdated entries
92   -S, --sudo                use sudo to update transitions file
93   -n, --no-action           don't do anything (only affects check)"""
94
95     sys.exit(exit_code)
96
97 ################################################################################
98
99 #####################################
100 #### This may run within sudo !! ####
101 #####################################
102 def load_transitions(trans_file):
103     # Parse the yaml file
104     sourcefile = file(trans_file, 'r')
105     sourcecontent = sourcefile.read()
106     failure = False
107     try:
108         trans = yaml.load(sourcecontent)
109     except yaml.YAMLError, exc:
110         # Someone fucked it up
111         print "ERROR: %s" % (exc)
112         return None
113
114     # lets do further validation here
115     checkkeys = ["source", "reason", "packages", "new", "rm"]
116
117     # If we get an empty definition - we just have nothing to check, no transitions defined
118     if type(trans) != dict:
119         # This can be anything. We could have no transitions defined. Or someone totally fucked up the
120         # file, adding stuff in a way we dont know or want. Then we set it empty - and simply have no
121         # transitions anymore. User will see it in the information display after he quit the editor and
122         # could fix it
123         trans = ""
124         return trans
125
126     try:
127         for test in trans:
128             t = trans[test]
129
130             # First check if we know all the keys for the transition and if they have
131             # the right type (and for the packages also if the list has the right types
132             # included, ie. not a list in list, but only str in the list)
133             for key in t:
134                 if key not in checkkeys:
135                     print "ERROR: Unknown key %s in transition %s" % (key, test)
136                     failure = True
137
138                 if key == "packages":
139                     if type(t[key]) != list:
140                         print "ERROR: Unknown type %s for packages in transition %s." % (type(t[key]), test)
141                         failure = True
142                     try:
143                         for package in t["packages"]:
144                             if type(package) != str:
145                                 print "ERROR: Packages list contains invalid type %s (as %s) in transition %s" % (type(package), package, test)
146                                 failure = True
147                             if re_broken_package.match(package):
148                                 # Someone had a space too much (or not enough), we have something looking like
149                                 # "package1 - package2" now.
150                                 print "ERROR: Invalid indentation of package list in transition %s, around package(s): %s" % (test, package)
151                                 failure = True
152                     except TypeError:
153                         # In case someone has an empty packages list
154                         print "ERROR: No packages defined in transition %s" % (test)
155                         failure = True
156                         continue
157
158                 elif type(t[key]) != str:
159                     if key == "new" and type(t[key]) == int:
160                         # Ok, debian native version
161                         continue
162                     else:
163                         print "ERROR: Unknown type %s for key %s in transition %s" % (type(t[key]), key, test)
164                         failure = True
165
166             # And now the other way round - are all our keys defined?
167             for key in checkkeys:
168                 if key not in t:
169                     print "ERROR: Missing key %s in transition %s" % (key, test)
170                     failure = True
171     except TypeError:
172         # In case someone defined very broken things
173         print "ERROR: Unable to parse the file"
174         failure = True
175
176
177     if failure:
178         return None
179
180     return trans
181
182 ################################################################################
183
184 #####################################
185 #### This may run within sudo !! ####
186 #####################################
187 def lock_file(f):
188     for retry in range(10):
189         lock_fd = os.open(f, os.O_RDWR | os.O_CREAT)
190         try:
191             fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
192             return lock_fd
193         except OSError, e:
194             if errno.errorcode[e.errno] == 'EACCES' or errno.errorcode[e.errno] == 'EEXIST':
195                 print "Unable to get lock for %s (try %d of 10)" % \
196                         (file, retry+1)
197                 time.sleep(60)
198             else:
199                 raise
200
201     utils.fubar("Couldn't obtain lock for %s." % (f))
202
203 ################################################################################
204
205 #####################################
206 #### This may run within sudo !! ####
207 #####################################
208 def write_transitions(from_trans):
209     """Update the active transitions file safely.
210        This function takes a parsed input file (which avoids invalid
211        files or files that may be be modified while the function is
212        active), and ensure the transitions file is updated atomically
213        to avoid locks."""
214
215     trans_file = Cnf["Dinstall::Reject::ReleaseTransitions"]
216     trans_temp = trans_file + ".tmp"
217
218     trans_lock = lock_file(trans_file)
219     temp_lock  = lock_file(trans_temp)
220
221     destfile = file(trans_temp, 'w')
222     yaml.dump(from_trans, destfile, default_flow_style=False)
223     destfile.close()
224
225     os.rename(trans_temp, trans_file)
226     os.close(temp_lock)
227     os.close(trans_lock)
228
229 ################################################################################
230
231 ##########################################
232 #### This usually runs within sudo !! ####
233 ##########################################
234 def write_transitions_from_file(from_file):
235     """We have a file we think is valid; if we're using sudo, we invoke it
236        here, otherwise we just parse the file and call write_transitions"""
237
238     # Lets check if from_file is in the directory we expect it to be in
239     if not os.path.abspath(from_file).startswith(Cnf["Transitions::TempPath"]):
240         print "Will not accept transitions file outside of %s" % (Cnf["Transitions::TempPath"])
241         sys.exit(3)
242
243     if Options["sudo"]:
244         os.spawnl(os.P_WAIT, "/usr/bin/sudo", "/usr/bin/sudo", "-u", "dak", "-H",
245               "/usr/local/bin/dak", "transitions", "--import", from_file)
246     else:
247         trans = load_transitions(from_file)
248         if trans is None:
249             raise TransitionsError, "Unparsable transitions file %s" % (file)
250         write_transitions(trans)
251
252 ################################################################################
253
254 def temp_transitions_file(transitions):
255     # NB: file is unlinked by caller, but fd is never actually closed.
256     # We need the chmod, as the file is (most possibly) copied from a
257     # sudo-ed script and would be unreadable if it has default mkstemp mode
258
259     (fd, path) = tempfile.mkstemp("", "transitions", Cnf["Transitions::TempPath"])
260     os.chmod(path, 0644)
261     f = open(path, "w")
262     yaml.dump(transitions, f, default_flow_style=False)
263     return path
264
265 ################################################################################
266
267 def edit_transitions():
268     trans_file = Cnf["Dinstall::Reject::ReleaseTransitions"]
269     edit_file = temp_transitions_file(load_transitions(trans_file))
270
271     editor = os.environ.get("EDITOR", "vi")
272
273     while True:
274         result = os.system("%s %s" % (editor, edit_file))
275         if result != 0:
276             os.unlink(edit_file)
277             utils.fubar("%s invocation failed for %s, not removing tempfile." % (editor, edit_file))
278
279         # Now try to load the new file
280         test = load_transitions(edit_file)
281
282         if test == None:
283             # Edit is broken
284             print "Edit was unparsable."
285             prompt = "[E]dit again, Drop changes?"
286             default = "E"
287         else:
288             print "Edit looks okay.\n"
289             print "The following transitions are defined:"
290             print "------------------------------------------------------------------------"
291             transition_info(test)
292
293             prompt = "[S]ave, Edit again, Drop changes?"
294             default = "S"
295
296         answer = "XXX"
297         while prompt.find(answer) == -1:
298             answer = utils.our_raw_input(prompt)
299             if answer == "":
300                 answer = default
301             answer = answer[:1].upper()
302
303         if answer == 'E':
304             continue
305         elif answer == 'D':
306             os.unlink(edit_file)
307             print "OK, discarding changes"
308             sys.exit(0)
309         elif answer == 'S':
310             # Ready to save
311             break
312         else:
313             print "You pressed something you shouldn't have :("
314             sys.exit(1)
315
316     # We seem to be done and also have a working file. Copy over.
317     write_transitions_from_file(edit_file)
318     os.unlink(edit_file)
319
320     print "Transitions file updated."
321
322 ################################################################################
323
324 def check_transitions(transitions):
325     to_dump = 0
326     to_remove = []
327     # Now look through all defined transitions
328     for trans in transitions:
329         t = transitions[trans]
330         source = t["source"]
331         expected = t["new"]
332
333         # Will be None if nothing is in testing.
334         current = database.get_suite_version(source, "testing")
335
336         print_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
337
338         if current == None:
339             # No package in testing
340             print "Transition source %s not in testing, transition still ongoing." % (source)
341         else:
342             compare = apt_pkg.VersionCompare(current, expected)
343             if compare < 0:
344                 # This is still valid, the current version in database is older than
345                 # the new version we wait for
346                 print "This transition is still ongoing, we currently have version %s" % (current)
347             else:
348                 print "REMOVE: This transition is over, the target package reached testing. REMOVE"
349                 print "%s wanted version: %s, has %s" % (source, expected, current)
350                 to_remove.append(trans)
351                 to_dump = 1
352         print "-------------------------------------------------------------------------"
353
354     if to_dump:
355         prompt = "Removing: "
356         for remove in to_remove:
357             prompt += remove
358             prompt += ","
359
360         prompt += " Commit Changes? (y/N)"
361         answer = ""
362
363         if Options["no-action"]:
364             answer="n"
365         else:
366             answer = utils.our_raw_input(prompt).lower()
367
368         if answer == "":
369             answer = "n"
370
371         if answer == 'n':
372             print "Not committing changes"
373             sys.exit(0)
374         elif answer == 'y':
375             print "Committing"
376             for remove in to_remove:
377                 del transitions[remove]
378
379             edit_file = temp_transitions_file(transitions)
380             write_transitions_from_file(edit_file)
381
382             print "Done"
383         else:
384             print "WTF are you typing?"
385             sys.exit(0)
386
387 ################################################################################
388
389 def print_info(trans, source, expected, rm, reason, packages):
390     print """Looking at transition: %s
391 Source:      %s
392 New Version: %s
393 Responsible: %s
394 Description: %s
395 Blocked Packages (total: %d): %s
396 """ % (trans, source, expected, rm, reason, len(packages), ", ".join(packages))
397     return
398
399 ################################################################################
400
401 def transition_info(transitions):
402     for trans in transitions:
403         t = transitions[trans]
404         source = t["source"]
405         expected = t["new"]
406
407         # Will be None if nothing is in testing.
408         current = database.get_suite_version(source, "testing")
409
410         print_info(trans, source, expected, t["rm"], t["reason"], t["packages"])
411
412         if current == None:
413             # No package in testing
414             print "Transition source %s not in testing, transition still ongoing." % (source)
415         else:
416             compare = apt_pkg.VersionCompare(current, expected)
417             print "Apt compare says: %s" % (compare)
418             if compare < 0:
419                 # This is still valid, the current version in database is older than
420                 # the new version we wait for
421                 print "This transition is still ongoing, we currently have version %s" % (current)
422             else:
423                 print "This transition is over, the target package reached testing, should be removed"
424                 print "%s wanted version: %s, has %s" % (source, expected, current)
425         print "-------------------------------------------------------------------------"
426
427 ################################################################################
428
429 def main():
430     global Cnf
431
432     #####################################
433     #### This can run within sudo !! ####
434     #####################################
435     init()
436
437     # Check if there is a file defined (and existant)
438     transpath = Cnf.get("Dinstall::Reject::ReleaseTransitions", "")
439     if transpath == "":
440         utils.warn("Dinstall::Reject::ReleaseTransitions not defined")
441         sys.exit(1)
442     if not os.path.exists(transpath):
443         utils.warn("ReleaseTransitions file, %s, not found." %
444                           (Cnf["Dinstall::Reject::ReleaseTransitions"]))
445         sys.exit(1)
446     # Also check if our temp directory is defined and existant
447     temppath = Cnf.get("Transitions::TempPath", "")
448     if temppath == "":
449         utils.warn("Transitions::TempPath not defined")
450         sys.exit(1)
451     if not os.path.exists(temppath):
452         utils.warn("Temporary path %s not found." %
453                           (Cnf["Transitions::TempPath"]))
454         sys.exit(1)
455
456     if Options["import"]:
457         try:
458             write_transitions_from_file(Options["import"])
459         except TransitionsError, m:
460             print m
461             sys.exit(2)
462         sys.exit(0)
463     ##############################################
464     #### Up to here it can run within sudo !! ####
465     ##############################################
466
467     # Parse the yaml file
468     transitions = load_transitions(transpath)
469     if transitions == None:
470         # Something very broken with the transitions, exit
471         utils.warn("Could not parse existing transitions file. Aborting.")
472         sys.exit(2)
473
474     if Options["edit"]:
475         # Let's edit the transitions file
476         edit_transitions()
477     elif Options["check"]:
478         # Check and remove outdated transitions
479         check_transitions(transitions)
480     else:
481         # Output information about the currently defined transitions.
482         print "Currently defined transitions:"
483         transition_info(transitions)
484
485     sys.exit(0)
486
487 ################################################################################
488
489 if __name__ == '__main__':
490     main()