land: Simplify retry checker.
[kai/samba-autobuild/.git] / script / land.py
1 #!/usr/bin/env python
2 # run tests on all Samba subprojects and push to a git tree on success
3 # Copyright Andrew Tridgell 2010
4 # Copyright Jelmer Vernooij 2010
5 # released under GNU GPL v3 or later
6
7 from cStringIO import StringIO
8 import fcntl
9 from subprocess import call, check_call, Popen, PIPE
10 import os, tarfile, sys, time
11 from optparse import OptionParser
12 import smtplib
13 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../selftest"))
14 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib/testtools"))
15 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib/subunit/python"))
16 import subunit
17 import testtools
18 import subunithelper
19 from email.mime.application import MIMEApplication
20 from email.mime.text import MIMEText
21 from email.mime.multipart import MIMEMultipart
22
23 samba_master = os.getenv('SAMBA_MASTER', 'git://git.samba.org/samba.git')
24 samba_master_ssh = os.getenv('SAMBA_MASTER_SSH', 'git+ssh://git.samba.org/data/git/samba.git')
25
26 cleanup_list = []
27
28 os.putenv('CC', "ccache gcc")
29
30 tasks = {
31     "source3" : [ ("autogen", "./autogen.sh", "text/plain"),
32                   ("configure", "./configure.developer ${PREFIX}", "text/plain"),
33                   ("make basics", "make basics", "text/plain"),
34                   ("make", "make -j 4 everything", "text/plain"), # don't use too many processes
35                   ("install", "make install", "text/plain"),
36                   ("test", "TDB_NO_FSYNC=1 make subunit-test", "text/x-subunit") ],
37
38     "source4" : [ ("configure", "./configure.developer ${PREFIX}", "text/plain"),
39                   ("make", "make -j", "text/plain"),
40                   ("install", "make install", "text/plain"),
41                   ("test", "TDB_NO_FSYNC=1 make subunit-test", "text/x-subunit") ],
42
43     "source4/lib/ldb" : [ ("configure", "./configure --enable-developer -C ${PREFIX}", "text/plain"),
44                           ("make", "make -j", "text/plain"),
45                           ("install", "make install", "text/plain"),
46                           ("test", "make test", "text/plain") ],
47
48     "lib/tdb" : [ ("autogen", "./autogen-waf.sh", "text/plain"),
49                   ("configure", "./configure --enable-developer -C ${PREFIX}", "text/plain"),
50                   ("make", "make -j", "text/plain"),
51                   ("install", "make install", "text/plain"),
52                   ("test", "make test", "text/plain") ],
53
54     "lib/talloc" : [ ("autogen", "./autogen-waf.sh", "text/plain"),
55                      ("configure", "./configure --enable-developer -C ${PREFIX}", "text/plain"),
56                      ("make", "make -j", "text/plain"),
57                      ("install", "make install", "text/plain"),
58                      ("test", "make test", "text/x-subunit"), ],
59
60     "lib/replace" : [ ("autogen", "./autogen-waf.sh", "text/plain"),
61                       ("configure", "./configure --enable-developer -C ${PREFIX}", "text/plain"),
62                       ("make", "make -j", "text/plain"),
63                       ("install", "make install", "text/plain"),
64                       ("test", "make test", "text/plain"), ],
65
66     "lib/tevent" : [ ("configure", "./configure --enable-developer -C ${PREFIX}", "text/plain"),
67                      ("make", "make -j", "text/plain"),
68                      ("install", "make install", "text/plain"),
69                      ("test", "make test", "text/plain"), ],
70 }
71
72
73 def run_cmd(cmd, dir=None, show=None, output=False, checkfail=True, shell=False):
74     if show is None:
75         show = options.verbose
76     if show:
77         print("Running: '%s' in '%s'" % (cmd, dir))
78     if output:
79         return Popen(cmd, stdout=PIPE, cwd=dir, shell=shell).communicate()[0]
80     elif checkfail:
81         return check_call(cmd, cwd=dir, shell=shell)
82     else:
83         return call(cmd, cwd=dir, shell=shell)
84
85
86 def clone_gitroot(test_master, revision="HEAD"):
87     run_cmd(["git", "clone", "--shared", gitroot, test_master])
88     if revision != "HEAD":
89         run_cmd(["git", "checkout", revision])
90
91
92 class RetryChecker(object):
93     """Check whether it is necessary to retry."""
94
95     def __init__(self, dir):
96         run_cmd(["git", "remote", "add", "-t", "master", "master", samba_master])
97         run_cmd(["git", "fetch", "master"])
98         cmd = '''set -e
99                 while :; do
100                   sleep 60
101                   git describe master/master > old_master.desc
102                   git fetch master
103                   git describe master/master > master.desc
104                   diff old_master.desc master.desc
105                 done
106                '''
107         self.proc = Popen(cmd, shell=True, cwd=self.dir)
108
109     def poll(self):
110         return self.proc.poll()
111
112     def kill(self):
113         self.proc.terminate()
114         self.proc.wait()
115         self.retry.proc = None
116
117
118 class TreeStageBuilder(object):
119     """Handle building of a particular stage for a tree.
120     """
121
122     def __init__(self, tree, name, command, fail_quickly=False):
123         self.tree = tree
124         self.name = name
125         self.command = command
126         self.fail_quickly = fail_quickly
127         self.status = None
128         self.stdin = open(os.devnull, 'r')
129
130     def start(self):
131         raise NotImplementedError(self.start)
132
133     def poll(self):
134         self.status = self.proc.poll()
135         return self.status
136
137     def kill(self):
138         if self.proc is not None:
139             try:
140                 run_cmd(["killbysubdir", self.tree.sdir], checkfail=False)
141             except OSError:
142                 # killbysubdir doesn't exist ?
143                 pass
144             self.proc.terminate()
145             self.proc.wait()
146             self.proc = None
147
148     @property
149     def failure_reason(self):
150         return "failed '%s' with status %d" % (self.cmd, self.status)
151
152     @property
153     def failed(self):
154         return (os.WIFSIGNALED(self.status) or os.WEXITSTATUS(self.status) != 0)
155
156
157 class PlainTreeStageBuilder(TreeStageBuilder):
158
159     def start(self):
160         print '%s: [%s] Running %s' % (self.name, self.name, self.command)
161         self.proc = Popen(self.command, shell=True, cwd=self.tree.dir,
162                           stdout=self.tree.stdout, stderr=self.tree.stderr,
163                           stdin=self.stdin)
164
165
166 class AbortingTestResult(subunithelper.TestsuiteEnabledTestResult):
167
168     def __init__(self, stage):
169         super(AbortingTestResult, self).__init__()
170         self.stage = stage
171
172     def addError(self, test, details=None):
173         self.stage.proc.terminate()
174
175     def addFailure(self, test, details=None):
176         self.stage.proc.terminate()
177
178
179 class SubunitTreeStageBuilder(TreeStageBuilder):
180
181     def __init__(self, tree, name, command, fail_quickly=False):
182         super(SubunitTreeStageBuilder, self).__init__(tree, name, command,
183                 fail_quickly)
184         self.failed_tests = []
185         self.subunit_path = os.path.join(gitroot,
186             "%s.%s.subunit" % (self.tree.tag, self.name))
187         self.tree.logfiles.append(
188             (self.subunit_path, os.path.basename(self.subunit_path),
189              "text/x-subunit"))
190         self.subunit = open(self.subunit_path, 'w')
191
192         formatter = subunithelper.PlainFormatter(False, True, {})
193         clients = [formatter, subunit.TestProtocolClient(self.subunit)]
194         if fail_quickly:
195             clients.append(AbortingTestResult(self))
196         self.subunit_server = subunit.TestProtocolServer(
197             testtools.MultiTestResult(*clients),
198             self.subunit)
199         self.buffered = ""
200
201     def start(self):
202         print '%s: [%s] Running' % (self.tree.name, self.name)
203         self.proc = Popen(self.command, shell=True, cwd=self.tree.dir,
204             stdout=PIPE, stderr=self.tree.stderr, stdin=self.stdin)
205         fd = self.proc.stdout.fileno()
206         fl = fcntl.fcntl(fd, fcntl.F_GETFL)
207         fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
208
209     def poll(self):
210         try:
211             data = self.proc.stdout.read()
212         except IOError:
213             return None
214         else:
215             self.buffered += data
216             buffered = ""
217             for l in self.buffered.splitlines(True):
218                 if l[-1] == "\n":
219                     self.subunit_server.lineReceived(l)
220                 else:
221                     buffered += l
222             self.buffered = buffered
223             self.status = self.proc.poll()
224             if self.status is not None:
225                 self.subunit.close()
226             return self.status
227
228
229 class TreeBuilder(object):
230     '''handle build of one directory'''
231
232     def __init__(self, name, sequence, fail_quickly=False):
233         self.name = name
234         self.fail_quickly = fail_quickly
235
236         self.tag = self.name.replace('/', '_')
237         self.sequence = sequence
238         self.next = 0
239         self.stdout_path = os.path.join(gitroot, "%s.stdout" % (self.tag, ))
240         self.stderr_path = os.path.join(gitroot, "%s.stderr" % (self.tag, ))
241         self.logfiles = [
242             (self.stdout_path, os.path.basename(self.stdout_path), "text/plain"),
243             (self.stderr_path, os.path.basename(self.stderr_path), "text/plain"),
244             ]
245         if options.verbose:
246             print("stdout for %s in %s" % (self.name, self.stdout_path))
247             print("stderr for %s in %s" % (self.name, self.stderr_path))
248         if os.path.exists(self.stdout_path):
249             os.unlink(self.stdout_path)
250         if os.path.exists(self.stderr_path):
251             os.unlink(self.stderr_path)
252         self.stdout = open(self.stdout_path, 'w')
253         self.stderr = open(self.stderr_path, 'w')
254         self.sdir = os.path.join(testbase, self.tag)
255         if name in ['pass', 'fail', 'retry']:
256             self.dir = self.sdir
257         else:
258             self.dir = os.path.join(self.sdir, self.name)
259         self.prefix = os.path.join(testbase, "prefix", self.tag)
260         run_cmd(["rm", "-rf", self.sdir])
261         cleanup_list.append(self.sdir)
262         cleanup_list.append(self.prefix)
263         os.makedirs(self.sdir)
264         run_cmd(["rm",  "-rf", self.sdir])
265         clone_gitroot(self.sdir, revision)
266         self.start_next()
267
268     def start_next(self):
269         if self.next == len(self.sequence):
270             print '%s: Completed OK' % self.name
271             self.done = True
272             self.stdout.close()
273             self.stderr.close()
274             return
275         (stage_name, cmd, output_mime_type) = self.sequence[self.next]
276         cmd = cmd.replace("${PREFIX}", "--prefix=%s" % self.prefix)
277         if output_mime_type == "text/plain":
278             self.stage = PlainTreeStageBuilder(self, stage_name, cmd,
279                 self.fail_quickly)
280         elif output_mime_type == "text/x-subunit":
281             self.stage = SubunitTreeStageBuilder(self, stage_name, cmd,
282                 self.fail_quickly)
283         else:
284             raise Exception("Unknown output mime type %s" % output_mime_type)
285         self.stage.start()
286         self.next += 1
287
288     def remove_logs(self):
289         for path, name, mime_type in self.logfiles:
290             os.unlink(path)
291
292     @property
293     def status(self):
294         return self.stage.status
295
296     def poll(self):
297         return self.stage.poll()
298
299     def kill(self):
300         if self.stage is not None:
301             self.stage.kill()
302             self.stage = None
303
304     @property
305     def failed(self):
306         if self.stage is None:
307             return False
308         return self.stage.failed
309
310     @property
311     def failure_reason(self):
312         return "%s: [%s] %s" % (self.name, self.stage.name,
313             self.stage.failure_reason)
314
315
316 class BuildList(object):
317     '''handle build of multiple directories'''
318
319     def __init__(self, tasklist, tasknames):
320         global tasks
321         self.tlist = []
322         self.tail_proc = None
323         self.retry = None
324         if tasknames == ['pass']:
325             tasks = { 'pass' : [ ("pass", '/bin/true', "text/plain") ]}
326         if tasknames == ['fail']:
327             tasks = { 'fail' : [ ("fail", '/bin/false', "text/plain") ]}
328         if tasknames == []:
329             tasknames = tasklist
330         for n in tasknames:
331             b = TreeBuilder(n, tasks[n], not options.fail_slowly)
332             self.tlist.append(b)
333         if options.retry:
334             self.retry = RetryChecker(self.sdir)
335             self.need_retry = False
336
337     def kill_kids(self):
338         if self.tail_proc is not None:
339             self.tail_proc.terminate()
340             self.tail_proc.wait()
341             self.tail_proc = None
342         if self.retry is not None:
343             self.retry.kill()
344         for b in self.tlist:
345             b.kill()
346
347     def wait_one(self):
348         while True:
349             none_running = True
350             for b in self.tlist:
351                 if b.stage is None:
352                     continue
353                 none_running = False
354                 if b.poll() is None:
355                     continue
356                 b.stage = None
357                 return b
358             if options.retry:
359                 ret = self.retry.poll()
360                 if ret:
361                     self.need_retry = True
362                     self.retry = None
363                     return None
364             if none_running:
365                 return None
366             time.sleep(0.1)
367
368     def run(self):
369         while True:
370             b = self.wait_one()
371             if options.retry and self.need_retry:
372                 self.kill_kids()
373                 print("retry needed")
374                 return (0, None, None, None, "retry")
375             if b is None:
376                 break
377             if b.failed:
378                 self.kill_kids()
379                 return (b.status, b.name, b.stage, b.tag, b.failure_reason)
380             b.start_next()
381         self.kill_kids()
382         return (0, None, None, None, "All OK")
383
384     def tarlogs(self, name=None, fileobj=None):
385         tar = tarfile.open(name=name, fileobj=fileobj, mode="w:gz")
386         for b in self.tlist:
387             for (path, name, mime_type) in b.logfiles:
388                 tar.add(path, arcname=name)
389         if os.path.exists("autobuild.log"):
390             tar.add("autobuild.log")
391         tar.close()
392
393     def attach_logs(self, outer):
394         f = StringIO()
395         self.tarlogs(fileobj=f)
396         msg = MIMEApplication(f.getvalue(), "x-gzip")
397         msg.add_header('Content-Disposition', 'attachment',
398                        filename="logs.tar.gz")
399         outer.attach(msg)
400
401     def remove_logs(self):
402         for b in self.tlist:
403             b.remove_logs()
404
405     def start_tail(self):
406         cmd = "tail -f *.stdout *.stderr"
407         self.tail_proc = Popen(cmd, shell=True, cwd=gitroot)
408
409
410 def cleanup():
411     if options.nocleanup:
412         return
413     print("Cleaning up ....")
414     for d in cleanup_list:
415         run_cmd(["rm", "-rf", d])
416
417
418 def find_git_root(p):
419     '''get to the top of the git repo'''
420     while p != '/':
421         if os.path.isdir(os.path.join(p, ".git")):
422             return p
423         p = os.path.abspath(os.path.join(p, '..'))
424     return None
425
426
427 def daemonize(logfile):
428     pid = os.fork()
429     if pid == 0: # Parent
430         os.setsid()
431         pid = os.fork()
432         if pid != 0: # Actual daemon
433             os._exit(0)
434     else: # Grandparent
435         os._exit(0)
436
437     import resource      # Resource usage information.
438     maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
439     if maxfd == resource.RLIM_INFINITY:
440         maxfd = 1024 # Rough guess at maximum number of open file descriptors.
441     for fd in range(0, maxfd):
442         try:
443             os.close(fd)
444         except OSError:
445             pass
446     os.open(logfile, os.O_RDWR | os.O_CREAT)
447     os.dup2(0, 1)
448     os.dup2(0, 2)
449
450
451 def rebase_tree(url):
452     print("Rebasing on %s" % url)
453     run_cmd(["git", "remote", "add", "-t", "master", "master", url], show=True,
454             dir=test_master)
455     run_cmd(["git", "fetch", "master"], show=True, dir=test_master)
456     if options.fix_whitespace:
457         run_cmd(["git", "rebase", "--whitespace=fix", "master/master"],
458                 show=True, dir=test_master)
459     else:
460         run_cmd(["git", "rebase", "master/master"], show=True, dir=test_master)
461     diff = run_cmd(["git", "--no-pager", "diff", "HEAD", "master/master"],
462         dir=test_master, output=True)
463     if diff == '':
464         print("No differences between HEAD and master/master - exiting")
465         sys.exit(0)
466
467 def push_to(url):
468     print("Pushing to %s" % url)
469     if options.mark:
470         run_cmd("EDITOR=script/commit_mark.sh git commit --amend -c HEAD",
471             dir=test_master, shell=True)
472         # the notes method doesn't work yet, as metze hasn't allowed
473         # refs/notes/* in master
474         # run_cmd("EDITOR=script/commit_mark.sh git notes edit HEAD",
475         #     dir=test_master)
476     run_cmd(["git", "remote", "add", "-t", "master", "pushto", url], show=True,
477         dir=test_master)
478     run_cmd(["git", "push", "pushto", "+HEAD:master"], show=True,
479         dir=test_master)
480
481 def_testbase = os.getenv("AUTOBUILD_TESTBASE", "/memdisk/%s" % os.getenv('USER'))
482
483 parser = OptionParser()
484 parser.add_option("--repository", help="repository to run tests for", default=None, type=str)
485 parser.add_option("--revision", help="revision to compile if not HEAD", default=None, type=str)
486 parser.add_option("--tail", help="show output while running", default=False, action="store_true")
487 parser.add_option("--keeplogs", help="keep logs", default=False, action="store_true")
488 parser.add_option("--nocleanup", help="don't remove test tree", default=False, action="store_true")
489 parser.add_option("--testbase", help="base directory to run tests in (default %s)" % def_testbase,
490                   default=def_testbase)
491 parser.add_option("--passcmd", help="command to run on success", default=None)
492 parser.add_option("--verbose", help="show all commands as they are run",
493                   default=False, action="store_true")
494 parser.add_option("--rebase", help="rebase on the given tree before testing",
495                   default=None, type='str')
496 parser.add_option("--rebase-master", help="rebase on %s before testing" % samba_master,
497                   default=False, action='store_true')
498 parser.add_option("--pushto", help="push to a git url on success",
499                   default=None, type='str')
500 parser.add_option("--push-master", help="push to %s on success" % samba_master_ssh,
501                   default=False, action='store_true')
502 parser.add_option("--mark", help="add a Tested-By signoff before pushing",
503                   default=False, action="store_true")
504 parser.add_option("--fix-whitespace", help="fix whitespace on rebase",
505                   default=False, action="store_true")
506 parser.add_option("--retry", help="automatically retry if master changes",
507                   default=False, action="store_true")
508 parser.add_option("--email", help="send email to the given address on failure",
509                   type='str', default=None)
510 parser.add_option("--always-email", help="always send email, even on success",
511                   action="store_true")
512 parser.add_option("--daemon", help="daemonize after initial setup",
513                   action="store_true")
514 parser.add_option("--fail-slowly", help="continue running tests even after one has already failed",
515                   action="store_true")
516
517
518 def email_failure(blist, status, failed_task, failed_stage, failed_tag, errstr):
519     '''send an email to options.email about the failure'''
520     user = os.getenv("USER")
521     text = '''
522 Dear Developer,
523
524 Your autobuild failed when trying to test %s with the following error:
525    %s
526
527 the autobuild has been abandoned. Please fix the error and resubmit.
528
529 You can see logs of the failed task here:
530
531   http://git.samba.org/%s/samba-autobuild/%s.stdout
532   http://git.samba.org/%s/samba-autobuild/%s.stderr
533
534 A summary of the autobuild process is here:
535
536   http://git.samba.org/%s/samba-autobuild/autobuild.log
537
538 or you can get full logs of all tasks in this job here:
539
540   http://git.samba.org/%s/samba-autobuild/logs.tar.gz
541
542 The top commit for the tree that was built was:
543
544 %s
545
546 ''' % (failed_task, errstr, user, failed_tag, user, failed_tag, user, user,
547        get_top_commit_msg(test_master))
548
549     msg = MIMEMultipart()
550     msg['Subject'] = 'autobuild failure for task %s during %s' % (
551         failed_task, failed_stage)
552     msg['From'] = 'autobuild@samba.org'
553     msg['To'] = options.email
554
555     main = MIMEText(text)
556     msg.attach(main)
557
558     blist.attach_logs(msg)
559
560     s = smtplib.SMTP()
561     s.connect()
562     s.sendmail(msg['From'], [msg['To']], msg.as_string())
563     s.quit()
564
565 def email_success(blist):
566     '''send an email to options.email about a successful build'''
567     user = os.getenv("USER")
568     text = '''
569 Dear Developer,
570
571 Your autobuild has succeeded.
572
573 '''
574
575     if options.keeplogs:
576         text += '''
577
578 you can get full logs of all tasks in this job here:
579
580   http://git.samba.org/%s/samba-autobuild/logs.tar.gz
581
582 ''' % user
583
584     text += '''
585 The top commit for the tree that was built was:
586
587 %s
588 ''' % (get_top_commit_msg(test_master),)
589
590     msg = MIMEMultipart()
591     msg['Subject'] = 'autobuild success'
592     msg['From'] = 'autobuild@samba.org'
593     msg['To'] = options.email
594
595     main = MIMEText(text, 'plain')
596     msg.attach(main)
597
598     blist.attach_logs(msg)
599
600     s = smtplib.SMTP()
601     s.connect()
602     s.sendmail(msg['From'], [msg['To']], msg.as_string())
603     s.quit()
604
605
606 (options, args) = parser.parse_args()
607
608 if options.retry:
609     if not options.rebase_master and options.rebase is None:
610         raise Exception('You can only use --retry if you also rebase')
611
612 testbase = os.path.join(options.testbase, "b%u" % (os.getpid(),))
613 test_master = os.path.join(testbase, "master")
614
615 if options.repository is not None:
616     repository = options.repository
617 else:
618     repository = os.getcwd()
619
620 gitroot = find_git_root(repository)
621 if gitroot is None:
622     raise Exception("Failed to find git root under %s" % repository)
623
624 # get the top commit message, for emails
625 if options.revision is not None:
626     revision = options.revision
627 else:
628     revision = "HEAD"
629
630 def get_top_commit_msg(reporoot):
631     return run_cmd(["git", "log", "-1"], dir=reporoot, output=True)
632
633 try:
634     os.makedirs(testbase)
635 except Exception, reason:
636     raise Exception("Unable to create %s : %s" % (testbase, reason))
637 cleanup_list.append(testbase)
638
639 if options.daemon:
640     logfile = os.path.join(testbase, "log")
641     print "Forking into the background, writing progress to %s" % logfile
642     daemonize(logfile)
643
644 while True:
645     try:
646         run_cmd(["rm", "-rf", test_master])
647         cleanup_list.append(test_master)
648         clone_gitroot(test_master, revision)
649     except:
650         cleanup()
651         raise
652
653     try:
654         if options.rebase is not None:
655             rebase_tree(options.rebase)
656         elif options.rebase_master:
657             rebase_tree(samba_master)
658         blist = BuildList(tasks, args)
659         if options.tail:
660             blist.start_tail()
661         (status, failed_task, failed_stage, failed_tag, errstr) = blist.run()
662         if status != 0 or errstr != "retry":
663             break
664         cleanup()
665     except:
666         cleanup()
667         raise
668
669 blist.kill_kids()
670 if options.tail:
671     print("waiting for tail to flush")
672     time.sleep(1)
673
674 if status == 0:
675     print errstr
676     if options.passcmd is not None:
677         print("Running passcmd: %s" % options.passcmd)
678         run_cmd(options.passcmd, dir=test_master, shell=True)
679     if options.pushto is not None:
680         push_to(options.pushto)
681     elif options.push_master:
682         push_to(samba_master_ssh)
683     if options.keeplogs:
684         blist.tarlogs("logs.tar.gz")
685         print("Logs in logs.tar.gz")
686     if options.always_email:
687         email_success(blist)
688     blist.remove_logs()
689     cleanup()
690     print(errstr)
691 else:
692     # something failed, gather a tar of the logs
693     blist.tarlogs("logs.tar.gz")
694
695     if options.email is not None:
696         email_failure(blist, status, failed_task, failed_stage, failed_tag,
697             errstr)
698
699     cleanup()
700     print(errstr)
701     print("Logs in logs.tar.gz")
702 sys.exit(status)