chiark / gitweb /
reworking adt-run for much new functionality
[autopkgtest.git] / runner / adt-run
1 #!/usr/bin/python2.4
2 #
3 # adt-run is part of autopkgtest
4 # autopkgtest is a tool for testing Debian binary packages
5 #
6 # autopkgtest is Copyright (C) 2006 Canonical Ltd.
7 #
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
21 #
22 # See the file CREDITS for a full list of credits information (often
23 # installed as /usr/share/doc/autopkgtest/CREDITS).
24
25 import signal
26 import optparse
27 import tempfile
28 import sys
29 import subprocess
30 import traceback
31 import urllib
32 import string
33 import re as regexp
34 import os
35 import errno
36
37 from optparse import OptionParser
38
39 tmpdir = None
40 testbed = None
41 errorcode = 0
42
43 signal.signal(signal.SIGINT, signal.SIG_DFL) # undo stupid Python SIGINT thing
44
45 class Quit:
46         def __init__(q,ec,m): q.ec = ec; q.m = m
47
48 def bomb(m): raise Quit(20, "unexpected error: %s" % m)
49 def badpkg(m): raise Quit(12, "erroneous package: %s" % m)
50 def report(tname, result): print '%-20s %s' % (tname, result)
51
52 class Unsupported:
53  def __init__(u, lno, m):
54         if lno >= 0: u.m = '%s (control line %d)' % (m, lno)
55         else: u.m = m
56  def report(u, tname):
57         global errorcode
58         errorcode != 2
59         report(tname, 'SKIP %s' % u.m)
60
61 def debug(m):
62         global opts
63         if not opts.debug: return
64         print >>sys.stderr, 'atd-run: debug:', m
65
66 def flatten(l):
67         return reduce((lambda a,b: a + b), l, []) 
68
69 class Path:
70  def __init__(p, tb, path, what, dir=False, tbscratch=None, xfmap=None
71                 lpath=None):
72         p.tb = tb
73         p.p = path
74         p.what = what
75         p.dir = dir
76         p.tbscratch = tbscratch
77         p.lpath = None
78         if p.tb:
79                 if p.p[:1] != '/':
80                         bomb("path %s specified as being in testbed but"
81                                 " not absolute: `%s'" % (what, p.p))
82                 p.local = None
83                 p.down = p.p
84         else:
85                 p.local = p.p
86                 p.down = None
87         if p.dir: p.dirsfx = '/'
88         else: p.dirsfx = ''
89  def path(p):
90         return p.p + p.dirsfx
91  def append(p, suffix, what, dir=False):
92         return Path(p.tb, p.path() + suffix, what=what, dir=dir,
93                         tbscratch=p.tbscratch)
94  def __str__(p):
95         if p.tb: pfx = '/VIRT'
96         elif p.p[:1] == '/': pfx = '/HOST'
97         else: pfx = './'
98         return pfx + p.p
99
100  def xfmapcopy(p, cud, dstdir):
101         if p.xfmap is None: return
102         srcdir = os.path.dirname(p.path()+'/')
103         dstdir = p.xfmapdstdir+'/'
104         for f in p.xfmap(file(p.local)):
105                 if '/' in f: bomb("control file %s mentions other filename"
106                                 "containing slash" % p.what)
107                 testbed.command(cud, (srcdir+f, dstdir+f))
108
109  def onhost(p, lpath = None):
110         if lpath is not None:
111                 if p.lpath is not None: assert(p.lpath == lpath)
112                 p.lpath = lpath
113         if p.local is not None:
114                 if p.lpath is not None: assert(p.local == p.lpath)
115                 return p.local
116         testbed.open()
117
118         if p.xfmap is None:
119                 p.local = p.lpath
120                 if p.local is None: p.local = tmpdir + '/tb-' + p.what
121         else:
122                 assert(p.lpath is None)
123                 assert(not p.dir)
124                 p.xfmapdstdir = tmpdir + '/tbd-' + p.what
125                 os.mkdir(p.xfmapdstdir)
126                 p.local = p.xfmapdstdir + '/' + os.path.basename(p.down)
127
128         testbed.command('copyup', (p.path(), p.local + p.dirsfx))
129         p.xfmapcopy('copyup')
130
131         return p.local
132
133  def maybe_onhost(p):
134         if p.lpath is None: return None
135         return p.onhost()
136
137  def ontb(p):
138         testbed.open()
139
140         if p.tbscratch is not None:
141                 if p.tbscratch != testbed.scratch:
142                         p.down = None
143         if p.down is not None: return p.down
144         if p.tb:
145                 bomb("testbed scratch path " + str(p) + " survived testbed")
146
147         if p.xfmap is None:
148                 p.down = testbed.scratch.p + '/host-' + p.what          
149         else:
150                 assert(not p.dir)
151                 p.xfmapdstdir = testbed.scratch.p + '/hostd-' + p.what
152                 testbed.command('mkdir '+p.xfmapdstdir)
153                 p.down = p.xfmapdstdir + '/' + os.path.basename(p.local)
154
155         p.tbscratch = testbed.scratch
156         testbed.command('copydown', (p.path(), p.down + p.dirsfx))
157         p.xfmapcopy('copydown')
158         return p.down
159
160 def parse_args():
161         global opts
162         usage = "%prog <options> -- <virt-server>..."
163         parser = OptionParser(usage=usage)
164         pa = parser.add_option
165         pe = parser.add_option
166
167         def cb_vserv(op,optstr,value,parser):
168                 parser.values.vserver = list(parser.rargs)
169                 del parser.rargs[:]
170
171         def cb_path(op,optstr,value,parser, long,tb,dir,xfmap):
172                 name = long.replace('-','_')
173                 path = Path(tb, value, long, dir, xfmap=xfmap)
174                 setattr(parser.values, name, path)
175
176         def pa_path(long, help, dir=False, xfmap=None):
177                 def papa_tb(long, ca, pahelp):
178                         pa('', long, action='callback', callback=cb_path,
179                                 nargs=1, type='string', callback_args=ca,
180                                 help=(help % pahelp), metavar='PATH')
181                 papa_tb('--'+long,      (long, False, dir, xfmap), 'host')
182                 papa_tb('--'+long+'-tb',(long, True, dir, xfmap), 'testbed')
183
184         pa_path('build-tree',   'use build tree from PATH on %s', dir=True)
185         pa_path('build-source', 'use tests in DSC on %s (building it)', xfmap=xfmap_dsc)
186         #nyi pa_path('install-binary', 'install package found in PATH on %s')
187         #nyi pa_path('install-from-source', 'build and install package found'+
188         #                       ' in PATH on %s', xfmap=xfmap_dsc)
189         #nyi these install-* options need cb_path to be able to make a list
190         # nyi: without-depends,with-depends-only,with-depends,with-recommends
191         # nyi: package-filter-dependency
192         # nyi: package-filter-from-source
193         pa_path('install-binary', 'build source package PATH on %s')
194         pa_path('control',    'read control file PATH on %s')
195         pa_path('output-dir', 'write stderr/out files in PATH on %s', dir=True)
196
197         # nyi: on testbed gain root command
198         pa('-d', '--debug', action='store_true', dest='debug');
199         pa('','--user', type='string', dest='user', metavar='USER',
200                 help='run tests as USER (needs root on testbed)')
201         pa('','--fakeroot', type='string', dest='fakeroot', metavar='FAKEROOT',
202                 help='prefix debian/rules build with FAKEROOT')
203
204         class SpecialOption(optparse.Option): pass
205         vs_op = SpecialOption('','--VSERVER-DUMMY')
206         vs_op.action = 'callback'
207         vs_op.type = None
208         vs_op.default = None
209         vs_op.nargs = 0
210         vs_op.callback = cb_vserv
211         vs_op.callback_args = ( )
212         vs_op.callback_kwargs = { }
213         vs_op.help = 'introduces virtualisation server and args'
214         vs_op._short_opts = []
215         #vs_op._long_opts = ['--DUMMY']
216         vs_op._long_opts = ['---']
217
218         pa(vs_op)
219
220         (opts,args) = parser.parse_args()
221         if not hasattr(opts,'vserver'):
222                 parser.error('you must specifiy --- <virt-server>...')
223
224         if opts.build_tree is not None and opts.build_source is not None:
225                 parser.error('do not specify both --build-tree and'
226                                 ' --build-source')
227
228         if opts.control is None:
229                 opts.control = opts.build_tree.append(
230                         'debian/tests/control', 'control')
231
232 def finalise_options():
233         global opts, testbed
234
235         if opts.build_tree is None and opts.build_source is None:
236                 opts.build_tree = Path(False, '.', 'build-tree', dir=True)
237
238         if opts.user is None and 'root-on-testbed' not in caps:
239                 opts.user = ''
240
241         if opts.user is None:
242                 su = 'suggested-normal-user='
243                 ul = [
244                         e[length(su):]
245                         for e in caps
246                         if e.startswith(su)
247                         ]
248                 if len(ul) > 1:
249                         print >>sys.stderr, "warning: virtualisation"
250                                 " system offers several suggested-normal-user"
251                                 " values: "+('/'.join(ul))+", using "+ul[0]
252                 if ul:
253                         opts.user = ul[0]
254                 else:
255                         opts.user = ''
256
257         if opts.user:
258                 if 'root-on-testbed' not in caps:
259                         print >>sys.stderr, "warning: virtualisation"
260                                 " system does not offer root on testbed,"
261                                 " but --user option specified: failure likely"
262                 opts.user_wrap = lambda x: 'su %s -c "%s"' % (opts.user, x)
263         else:
264                 opts.user_wrap = lambda x: x
265
266         if opts.fakeroot is None:
267                 opts.fakeroot = ''
268                 if opts.user or
269                    'root-on-testbed' not in testbed.caps:
270                         opts.fakeroot = 'fakeroot'
271
272 logpath_counters = {}
273
274 def logpath(idstr):
275         # if idstr ends with `-' then a counter is appended
276         if idstr.endswith('-'):
277                 if not logpath_counters.has_key(idstr):
278                         logpath_counters[idstr] = 1
279                 else:
280                         logpath_counters[idstr] += 1
281                 idstr.append(`logpath_counters[idstr]`)
282         idstr = 'log-' + idstr
283         if opts.output_dir is None:
284                 return testbed.scratch.append(idstr, idstr)
285         elif opts.output_dir.tb:
286                 return opts.output_dir.append(idstr, idstr)
287         else:
288                 return Path(True, testbed.scratch.p, idstr,
289                         lpath=opts.output_dir.p+'/'+idstr)
290
291 class Testbed:
292  def __init__(tb):
293         tb.sp = None
294         tb.lastsend = None
295         tb.scratch = None
296  def start(tb):
297         p = subprocess.PIPE
298         tb.sp = subprocess.Popen(opts.vserver,
299                 stdin=p, stdout=p, stderr=None)
300         tb.expect('ok')
301         tb.caps = tb.command('capabilities')
302  def stop(tb):
303         tb.close()
304         if tb.sp is None: return
305         ec = tb.sp.returncode
306         if ec is None:
307                 tb.sp.stdout.close()
308                 tb.send('quit')
309                 tb.sp.stdin.close()
310                 ec = tb.sp.wait()
311         if ec:
312                 tb.bomb('testbed gave exit status %d after quit' % ec)
313  def open(tb):
314         if tb.scratch is not None: return
315         p = tb.commandr1('open')
316         tb.scratch = Path(True, p, 'tb-scratch', dir=True)
317         tb.scratch.tbscratch = tb.scratch
318  def close(tb):
319         if tb.scratch is None: return
320         tb.scratch = None
321         if tb.sp is None: return
322         tb.command('close')
323  def bomb(tb, m):
324         if tb.sp is not None:
325                 tb.sp.stdout.close()
326                 tb.sp.stdin.close()
327                 ec = tb.sp.wait()
328                 if ec: print >>sys.stderr, ('adt-run: testbed failing,'
329                         ' exit status %d' % ec)
330         tb.sp = None
331         raise Quit(16, 'testbed failed: %s' % m)
332  def send(tb, string):
333         tb.sp.stdin
334         try:
335                 debug('>> '+string)
336                 print >>tb.sp.stdin, string
337                 tb.sp.stdin.flush()
338                 tb.lastsend = string
339         except:
340                 (type, value, dummy) = sys.exc_info()
341                 tb.bomb('cannot send to testbed: %s' % traceback.
342                         format_exception_only(type, value))
343  def expect(tb, keyword, nresults=-1):
344         l = tb.sp.stdout.readline()
345         if not l: tb.bomb('unexpected eof from the testbed')
346         if not l.endswith('\n'): tb.bomb('unterminated line from the testbed')
347         l = l.rstrip('\n')
348         debug('<< '+l)
349         ll = l.split()
350         if not ll: tb.bomb('unexpected whitespace-only line from the testbed')
351         if ll[0] != keyword:
352                 if tb.lastsend is None:
353                         tb.bomb("got banner `%s', expected `%s...'" %
354                                 (l, keyword))
355                 else:
356                         tb.bomb("sent `%s', got `%s', expected `%s...'" %
357                                 (tb.lastsend, l, keyword))
358         ll = ll[1:]
359         if nresults >= 0 and len(ll) != nresults:
360                 tb.bomb("sent `%s', got `%s' (%d result parameters),"
361                         " expected %d result parameters" %
362                         (string, l, len(ll), nresults))
363         return ll
364  def commandr(tb, cmd, nresults, args=()):
365         if type(cmd) is str: cmd = [cmd]
366         al = cmd + map(urllib.quote, args)
367         tb.send(string.join(al))
368         ll = tb.expect('ok')
369         rl = map(urllib.unquote, ll)
370         return rl
371  def command(tb, cmd, args=()):
372         tb.commandr(cmd, 0, args)
373  def commandr1(tb, cmd, args=()):
374         rl = tb.commandr(cmd, 1, args)
375         return rl[0]
376
377 class FieldBase:
378  def __init__(f, fname, stz, base, tnames, vl):
379         assert(vl)
380         f.stz = stz
381         f.base = base
382         f.tnames = tnames
383         f.vl = vl
384  def words(f):
385         def distribute(vle):
386                 (lno, v) = vle
387                 r = v.split()
388                 r = map((lambda w: (lno, w)), r)
389                 return r
390         return flatten(map(distribute, f.vl))
391  def atmostone(f):
392         if len(vl) == 1:
393                 (f.lno, f.v) = vl[0]
394         else:
395                 raise Unsupported(f.vl[1][0],
396                         'only one %s field allowed' % fn)
397         return f.v
398
399 def build_some_source(keyletter, dsc, binaries=False):
400         idstr = 'build'+keyletter
401         testbed.open()
402         bd = testbed.scratch.append(idstr, idstr)
403         script = [
404                         'exec 3>&1 >&2'
405                         'mkdir '+bd.ontb(),
406                         'cd '+bd.ontb(),
407                         'dpkg-source -x '+dsc.ontb()+' >&2',
408                         'cd */.',
409                         'pwd >&3',
410                         opts.user_wrap('debian/rules build'),
411                         ]
412         if binaries:
413                 script = script + [
414                         opts.user_wrap(opts.fakeroot+' debian/rules binary'),
415                         'cd ..',
416                         'echo *.deb >&3',
417                         ]
418
419         script = '\n'.join(script)
420         so = testbed.scratch.append(idstr+'-tree-path')
421         se = logpath('log-'+idstr)
422         rc = testbed.commandr1(['execute',
423                 ','.join(map(urllib.quote, ['sh','-xec',script]))],
424                 '/dev/null',so.ontb(),se.ontb(), testbed.scratch.ontb())
425         sod = file(so.onhost()).read().split("\n")
426         build_tree = Path(True, sod[0], idstr+'-tree', dir=True)
427         se.maybe_onhost()
428         return (build_tree,)
429
430 def acquire_built_source():
431         global opts
432
433         if opts.build_source:
434                 assert(opts.build_tree is None)
435                 bss = build_some_source('t', opts.build_source)
436                 opts.build_tree = bss[0]
437
438 class FieldIgnore(FieldBase):
439  def parse(f): pass
440
441 class Restriction:
442  def __init__(r,rname,base): pass
443
444 class Restriction_rw_build_tree(Restriction): pass
445
446 class Field_Restrictions(FieldBase):
447  def parse(f):
448         for wle in f.words():
449                 (lno, rname) = wle
450                 rname = rname.replace('-','_')
451                 try: rclass = globals()['Restriction_'+rname]
452                 except KeyError: raise Unsupported(lno,
453                         'unknown restriction %s' % rname)
454                 r = rclass(rname, f.base)
455                 f.base['restrictions'].append(r)
456
457 class Field_Tests(FieldIgnore): pass
458
459 class Field_Tests_directory(FieldBase):
460  def parse(f):
461         td = atmostone(f)
462         if td.startswith('/'): raise Unspported(f.lno,
463                 'Tests-Directory may not be absolute')
464         base['testsdir'] = td
465
466 def run_tests():
467         for t in tests:
468                 t.run()
469         if not tests:
470                 global errorcode
471                 report('*', 'SKIP no tests in this package')
472                 errorcode |= 8
473
474 class Test:
475  def __init__(t, tname, base):
476         if '/' in tname: raise Unsupported(base[' lno'],
477                 'test name may not contain / character')
478         for k in base: setattr(t,k,base[k])
479         t.tname = tname
480         if len(base['testsdir']): tpath = base['testsdir'] + '/' + tname
481         else: tpath = tname
482         t.p = opts.build_tree.append(tpath, 'test-'+tname)
483  def report(t, m):
484         report(t.tname, m)
485  def reportfail(t, m):
486         global errorcode
487         errorcode |= 4
488         report(t.tname, 'FAIL ' + m)
489  def run(t):
490         testbed.open()
491         def stdouterr(oe):
492                 idstr = oe + '-' + t.tname
493                 if opts.output_dir is not None and opts.output_dir.tb:
494                         return opts.output_dir.append(idstr)
495                 else:
496                         return testbed.scratch.append(idstr, idstr)
497         def stdouterrh(p, oe):
498                 idstr = oe + '-' + t.tname
499                 if opts.output_dir is None or opts.output_dir.tb:
500                         return p.onhost()
501                 else:
502                         return p.onhost(opts.output_dir.onhost() + '/' + idstr)
503         so = stdouterr('stdout')
504         se = stdouterr('stderr')
505         rc = testbed.commandr1('execute',(t.p.ontb(),
506                 '/dev/null', so.ontb(), se.ontb(), opts.build_tree.ontb()))
507         soh = stdouterrh(so, 'stdout')
508         seh = stdouterrh(se, 'stderr')
509         rc = int(rc)
510         stab = os.stat(seh)
511         if stab.st_size != 0:
512                 l = file(seh).readline()
513                 l = l.rstrip('\n \t\r')
514                 if len(l) > 40: l = l[:40] + '...'
515                 t.reportfail('stderr: %s' % l)
516         elif rc != 0:
517                 t.reportfail('non-zero exit status %d' % rc)
518         else:
519                 t.report('PASS')
520
521 def read_control():
522         global tests
523         try:
524                 control = file(opts.control.onhost(), 'r')
525         except IOError, oe:
526                 if oe[0] != errno.ENOENT: raise
527                 tests = []
528                 return
529         lno = 0
530         def badctrl(m): testbed.badpkg('tests/control line %d: %s' % (lno, m))
531         stz = None # stz[field_name][index] = (lno, value)
532
533         stanzas = [ ]
534         stz = None
535
536         def end_stanza(stz):
537                 if stz is None: return
538                 stz[' errs'] = 0
539                 stanzas.append(stz)
540                 stz = None
541                 hcurrent = None
542
543         initre = regexp.compile('([A-Z][-0-9a-z]*)\s*\:\s*(.*)$')
544         while 1:
545                 l = control.readline()
546                 if not l: break
547                 lno += 1
548                 if not l.endswith('\n'): badctrl('unterminated line')
549                 if regexp.compile('\s*\#').match(l): continue
550                 if not regexp.compile('\S').match(l): end_stanza(stz); continue
551                 initmat = initre.match(l)
552                 if initmat:
553                         (fname, l) = initmat.groups()
554                         fname = string.capwords(fname)
555                         if stz is None:
556                                 stz = { ' lno': lno }
557                         if not stz.has_key(fname): stz[fname] = [ ]
558                         hcurrent = stz[fname]
559                 elif regexp.compile('\s').match(l):
560                         if not hcurrent: badctrl('unexpected continuation')
561                 else:
562                         badctrl('syntax error')
563                 hcurrent.append((lno, l))
564         end_stanza(stz)
565
566         def testbadctrl(stz, lno, m):
567                 report_badctrl(lno, m)
568                 stz[' errs'] += 1
569
570         for stz in stanzas:
571                 try:
572                         try: tnames = stz['Tests']
573                         except KeyError:
574                                 tnames = ['*']
575                                 raise Unsupported(stz[' lno'],
576                                         'no Tests field')
577                         tnames = map((lambda lt: lt[1]), tnames)
578                         tnames = string.join(tnames).split()
579                         base = {
580                                 'restrictions': [],
581                                 'testsdir': 'debian/tests'
582                         }
583                         for fname in stz.keys():
584                                 if fname.startswith(' '): continue
585                                 vl = stz[fname]
586                                 try: fclass = globals()['Field_'+
587                                         fname.replace('-','_')]
588                                 except KeyError: raise Unsupported(vl[0][0],
589                                         'unknown metadata field %s' % fname)
590                                 f = fclass(stz, fname, base, tnames, vl)
591                                 f.parse()
592                         tests = []
593                         for tname in tnames:
594                                 t = Test(tname, base)
595                                 tests.append(t)
596                 except Unsupported, u:
597                         for tname in tnames: u.report(tname)
598                         continue
599
600 def print_exception(ei, msgprefix=''):
601         if msgprefix: print >>sys.stderr, msgprefix
602         (et, q, tb) = ei
603         if et is Quit:
604                 print >>sys.stderr, 'adt-run:', q.m
605                 return q.ec
606         else:
607                 print >>sys.stderr, "adt-run: unexpected, exceptional, error:"
608                 traceback.print_exc()
609                 return 20
610
611 def cleanup():
612         try:
613                 rm_ec = 0
614                 if tmpdir is not None:
615                         rm_ec = subprocess.call(['rm','-rf','--',tmpdir])
616                 if testbed is not None:
617                         testbed.stop()
618                 if rm_ec: bomb('rm -rf -- %s failed, code %d' % (tmpdir, ec))
619         except:
620                 print_exception(sys.exc_info(),
621                         '\nadt-run: error cleaning up:\n')
622                 os._exit(20)
623
624 def main():
625         global testbed
626         global tmpdir
627         try:
628                 parse_args()
629         except SystemExit, se:
630                 os._exit(20)
631         try:
632                 tmpdir = tempfile.mkdtemp()
633                 testbed = Testbed()
634                 testbed.start()
635                 testbed.open()
636                 testbed.close()
637                 finalise_options()
638                 read_control()
639                 run_tests()
640         except:
641                 ec = print_exception(sys.exc_info(), '')
642                 cleanup()
643                 os._exit(ec)
644         cleanup()
645         os._exit(errorcode)
646
647 main()