chiark / gitweb /
build system and licensing - subject to confirmation
[autopkgtest.git] / runner / adt-run
1 #!/usr/bin/python2.4
2 # usage:
3 #       adt-run <options>... --- <virt-server> [<virt-server-arg>...]
4 #
5 # invoke in toplevel of package (not necessarily built)
6 # with package installed
7
8 # exit status:
9 #  0 all tests passed
10 #  4 at least one test failed
11 #  8 no tests in this package
12 # 12 erroneous package
13 # 16 testbed failure
14 # 20 other unexpected failures including bad usage
15
16 # adt-run is part of autodebtest
17 # autodebtest is a tool for testing Debian binary packages
18 #
19 # autodebtest is Copyright (C) 2006 Canonical Ltd.
20 #
21 # This program is free software; you can redistribute it and/or modify
22 # it under the terms of the GNU General Public License as published by
23 # the Free Software Foundation; either version 2 of the License, or
24 # (at your option) any later version.
25 #
26 # This program is distributed in the hope that it will be useful,
27 # but WITHOUT ANY WARRANTY; without even the implied warranty of
28 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
29 # GNU General Public License for more details.
30 #
31 # You should have received a copy of the GNU General Public License
32 # along with this program; if not, write to the Free Software
33 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
34 #
35 # See the file CREDITS for a full list of credits information (often
36 # installed as /usr/share/doc/autodebtest/CREDITS).
37
38 import signal
39 import optparse
40 import tempfile
41 import sys
42 import subprocess
43 import traceback
44 import urllib
45 import string
46 import re as regexp
47 import os
48
49 from optparse import OptionParser
50
51 tmpdir = None
52 testbed = None
53
54 signal.signal(signal.SIGINT, signal.SIG_DFL) # undo stupid Python SIGINT thing
55
56 class Quit:
57         def __init__(q,ec,m): q.ec = ec; q.m = m
58
59 def bomb(m): raise Quit(20, "unexpected error: %s" % m)
60 def badpkg(m): raise Quit(12, "erroneous package: %s" % m)
61 def report(tname, result): print '%-20s %s' % (tname, result)
62
63 class Unsupported:
64  def __init__(u, lno, m):
65         if lno >= 0: u.m = '%s (control line %d)' % (m, lno)
66         else: u.m = m
67  def report(u, tname):
68         report(tname, 'SKIP %s' % u.m)
69
70 def debug(m):
71         global opts
72         if not opts.debug: return
73         print >>sys.stderr, 'atd-run: debug:', m
74
75 def flatten(l):
76         return reduce((lambda a,b: a + b), l, []) 
77
78 class Path:
79  def __init__(p, tb, path, what, dir=False):
80         p.tb = tb
81         p.p = path
82         p.what = what
83         p.dir = dir
84         if p.tb:
85                 if p.p[:1] != '/':
86                         bomb("path %s specified as being in testbed but"
87                                 " not absolute: `%s'" % (what, p.p))
88                 p.local = None
89                 p.down = p.p
90         else:
91                 p.local = p.p
92                 p.down = None
93         if p.dir: p.dirsfx = '/'
94         else: p.dirsfx = ''
95  def path(p):
96         return p.p + p.dirsfx
97  def append(p, suffix, what, dir=False):
98         return Path(p.tb, p.path() + suffix, what=what, dir=dir)
99  def __str__(p):
100         if p.tb: pfx = '/VIRT'
101         elif p.p[:1] == '/': pfx = '/HOST'
102         else: pfx = './'
103         return pfx + p.p
104  def onhost(p):
105         if p.local is not None: return p.local
106         testbed.open()
107         p.local = tmpdir + '/tb-' + p.what
108         testbed.command('copyup', (p.path(), p.local + p.dirsfx))
109         return p.local
110  def ontb(p):
111         if p.down is not None: return p.down
112         p.down = testbed.scratch.p + '/host-' + p.what
113         testbed.command('copydown', (p.path(), p.down + p.dirsfx))
114         return p.down
115
116 def parse_args():
117         global opts
118         usage = "%prog <options> -- <virt-server>..."
119         parser = OptionParser(usage=usage)
120         pa = parser.add_option
121         pe = parser.add_option
122
123         def cb_vserv(op,optstr,value,parser):
124                 parser.values.vserver = list(parser.rargs)
125                 del parser.rargs[:]
126
127         def cb_path(op,optstr,value,parser, long,tb,dir):
128                 name = long.replace('-','_')
129                 setattr(parser.values, name, Path(tb, value, long, dir))
130
131         def pa_path(long, dir, help):
132                 def papa_tb(long, ca, pahelp):
133                         pa('', long, action='callback', callback=cb_path,
134                                 nargs=1, type='string', callback_args=ca,
135                                 help=(help % pahelp), metavar='PATH')
136                 papa_tb('--'+long,      (long, False, dir), 'host')
137                 papa_tb('--'+long+'-tb',(long, True, dir), 'testbed')
138
139         pa_path('build-tree',   True, 'use build tree from PATH on %s')
140         pa_path('control',      False, 'read control file PATH on %s')
141
142         pa('-d', '--debug', action='store_true', dest='debug');
143         pa('','--user', type='string',
144                 help='run tests as USER (needs root on testbed)')
145
146         class SpecialOption(optparse.Option): pass
147         vs_op = SpecialOption('','--VSERVER-DUMMY')
148         vs_op.action = 'callback'
149         vs_op.type = None
150         vs_op.default = None
151         vs_op.nargs = 0
152         vs_op.callback = cb_vserv
153         vs_op.callback_args = ( )
154         vs_op.callback_kwargs = { }
155         vs_op.help = 'introduces virtualisation server and args'
156         vs_op._short_opts = []
157         #vs_op._long_opts = ['--DUMMY']
158         vs_op._long_opts = ['---']
159
160         pa(vs_op)
161
162         (opts,args) = parser.parse_args()
163         if not hasattr(opts,'vserver'):
164                 parser.error('you must specifiy --- <virt-server>...')
165
166         if opts.build_tree is None:
167                 opts.build_tree = Path(False, '.', 'build-tree', dir=True)
168         if opts.control is None:
169                 opts.control = opts.build_tree.append(
170                         'debian/tests/control', 'control')
171
172 class Testbed:
173  def __init__(tb):
174         tb.sp = None
175         tb.lastsend = None
176         tb.scratch = None
177  def start(tb):
178         p = subprocess.PIPE
179         tb.sp = subprocess.Popen(opts.vserver,
180                 stdin=p, stdout=p, stderr=None)
181         tb.expect('ok')
182  def stop(tb):
183         tb.close()
184         if tb.sp is None: return
185         ec = tb.sp.returncode
186         if ec is None:
187                 tb.sp.stdout.close()
188                 tb.send('quit')
189                 tb.sp.stdin.close()
190                 ec = tb.sp.wait()
191         if ec:
192                 tb.bomb('testbed gave exit status %d after quit' % ec)
193  def open(tb):
194         if tb.scratch is not None: return
195         p = tb.commandr1('open')
196         tb.scratch = Path(True, p, 'tb-scratch', dir=True)
197  def close(tb):
198         if tb.scratch is None: return
199         tb.scratch = None
200         tb.command('close')
201  def bomb(tb, m):
202         if tb.sp is not None:
203                 tb.sp.stdout.close()
204                 tb.sp.stdin.close()
205                 ec = tb.sp.wait()
206                 if ec: print >>sys.stderr, ('adt-run: testbed failing,'
207                         ' exit status %d' % ec)
208         tb.sp = None
209         raise Quit(16, 'testbed failed: %s' % m)
210  def send(tb, string):
211         try:
212                 debug('>> '+string)
213                 print >>tb.sp.stdin, string
214                 tb.sp.stdin.flush()
215                 tb.lastsend = string
216         except:
217                 tb.bomb('cannot send to testbed: %s' %
218                         formatexception_only(sys.last_type, sys.last_value))
219  def expect(tb, keyword, nresults=-1):
220         l = tb.sp.stdout.readline()
221         if not l: tb.bomb('unexpected eof from the testbed')
222         if not l.endswith('\n'): tb.bomb('unterminated line from the testbed')
223         l = l.rstrip('\n')
224         debug('<< '+l)
225         ll = l.split()
226         if not ll: tb.bomb('unexpected whitespace-only line from the testbed')
227         if ll[0] != keyword:
228                 if tb.lastsend is None:
229                         tb.bomb("got banner `%s', expected `%s...'" %
230                                 (l, keyword))
231                 else:
232                         tb.bomb("sent `%s', got `%s', expected `%s...'" %
233                                 (tb.lastsend, l, keyword))
234         ll = ll[1:]
235         if nresults >= 0 and len(ll) != nresults:
236                 tb.bomb("sent `%s', got `%s' (%d result parameters),"
237                         " expected %d result parameters" %
238                         (string, l, len(ll), nresults))
239         return ll
240  def commandr(tb, cmd, nresults, args=()):
241         al = [cmd] + map(urllib.quote, args)
242         tb.send(string.join(al))
243         ll = tb.expect('ok')
244         rl = map(urllib.unquote, ll)
245         return rl
246  def command(tb, cmd, args=()):
247         tb.commandr(cmd, 0, args)
248  def commandr1(tb, cmd, args=()):
249         rl = tb.commandr(cmd, 1, args)
250         return rl[0]
251
252 class FieldBase:
253  def __init__(f, fname, stz, base, tnames, vl):
254         assert(vl)
255         f.stz = stz
256         f.base = base
257         f.tnames = tnames
258         f.vl = vl
259  def words(f):
260         def distribute(vle):
261                 (lno, v) = vle
262                 r = v.split()
263                 r = map((lambda w: (lno, w)), r)
264                 return r
265         return flatten(map(distribute, f.vl))
266  def atmostone(f):
267         if len(vl) == 1:
268                 (f.lno, f.v) = vl[0]
269         else:
270                 raise Unsupported(f.vl[1][0],
271                         'only one %s field allowed' % fn)
272         return f.v
273
274 class FieldIgnore(FieldBase):
275  def parse(f): pass
276
277 class Restriction:
278  def __init__(r,rname,base): pass
279
280 class Restriction_rw_build_tree(Restriction): pass
281
282 class Field_Restrictions(FieldBase):
283  def parse(f):
284         for wle in f.words():
285                 (lno, rname) = wle
286                 rname = rname.replace('-','_')
287                 try: rclass = globals()['Restriction_'+rname]
288                 except KeyError: raise Unsupported(lno,
289                         'unknown restriction %s' % rname)
290                 r = rclass(rname, f.base)
291                 f.base['restrictions'].append(r)
292
293 class Field_Tests(FieldIgnore): pass
294
295 class Field_Tests_directory(FieldBase):
296  def parse(f):
297         td = atmostone(f)
298         if td.startswith('/'): raise Unspported(f.lno,
299                 'Tests-Directory may not be absolute')
300         base['testsdir'] = td
301
302 def run_tests():
303         testbed.close()
304         for t in tests:
305                 t.run()
306
307 class Test:
308  def __init__(t, tname, base):
309         if '/' in tname: raise Unsupported(base[' lno'],
310                 'test name may not contain / character')
311         for k in base: setattr(t,k,base[k])
312         t.tname = tname
313         t.p = opts.build_tree.append(tname, 'test-'+tname)
314         t.p.ontb()
315  def report(t, m):
316         report(t.tname, m)
317  def run(t):
318         testbed.open()
319         so = testbed.scratch.append('stdout-' + t.tname, 'stdout-' + t.tname)
320         se = testbed.scratch.append('stdout-' + t.tname, 'stdout-' + t.tname)
321         rc = testbed.commandr1('execute',(t.p.ontb(),
322                 '/dev/null', so.ontb(), se.ontb()))
323         soh = so.onhost()
324         seh = se.onhost()
325         testbed.close()
326         rc = int(rc)
327         stab = os.stat(soh)
328         if stab.st_size != 0:
329                 l = file(seh).readline()
330                 l = l.rstrip('\n \t\r')
331                 if len(l) > 40: l = l[:40] + '...'
332                 t.report('FAIL stderr: %s' % l)
333         elif rc != 0:
334                 t.report('FAIL non-zero exit status %d' % rc)
335         else:
336                 t.report('PASS')
337
338 def read_control():
339         global tests
340         control = file(opts.control.onhost(), 'r')
341         lno = 0
342         def badctrl(m): testbed.badpkg('tests/control line %d: %s' % (lno, m))
343         stz = None # stz[field_name][index] = (lno, value)
344
345         stanzas = [ ]
346         stz = None
347
348         def end_stanza(stz):
349                 if stz is None: return
350                 stz[' errs'] = 0
351                 stanzas.append(stz)
352                 stz = None
353                 hcurrent = None
354
355         initre = regexp.compile('([A-Z][-0-9a-z]*)\s*\:\s*(.*)$')
356         while 1:
357                 l = control.readline()
358                 if not l: break
359                 lno += 1
360                 if not l.endswith('\n'): badctrl('unterminated line')
361                 if regexp.compile('\s*\#').match(l): continue
362                 if not regexp.compile('\S').match(l): end_stanza(stz); continue
363                 initmat = initre.match(l)
364                 if initmat:
365                         (fname, l) = initmat.groups()
366                         fname = string.capwords(fname)
367                         if stz is None:
368                                 stz = { ' lno': lno }
369                         if not stz.has_key(fname): stz[fname] = [ ]
370                         hcurrent = stz[fname]
371                 elif regexp.compile('\s').match(l):
372                         if not hcurrent: badctrl('unexpected continuation')
373                 else:
374                         badctrl('syntax error')
375                 hcurrent.append((lno, l))
376         end_stanza(stz)
377
378         def testbadctrl(stz, lno, m):
379                 report_badctrl(lno, m)
380                 stz[' errs'] += 1
381
382         for stz in stanzas:
383                 try:
384                         try: tnames = stz['Tests']
385                         except KeyError:
386                                 tnames = ['*']
387                                 raise Unsupported(stz[' lno'],
388                                         'no Tests field')
389                         tnames = map((lambda lt: lt[1]), tnames)
390                         tnames = string.join(tnames).split()
391                         base = {
392                                 'restrictions': [],
393                                 'testsdir': 'debian/tests'
394                         }
395                         for fname in stz.keys():
396                                 if fname.startswith(' '): continue
397                                 vl = stz[fname]
398                                 try: fclass = globals()['Field_'+
399                                         fname.replace('-','_')]
400                                 except KeyError: raise Unsupported(vl[0][0],
401                                         'unknown metadata field %s' % fname)
402                                 f = fclass(stz, fname, base, tnames, vl)
403                                 f.parse()
404                         tests = []
405                         for tname in tnames:
406                                 t = Test(tname, base)
407                                 tests.append(t)
408                 except Unsupported, u:
409                         for tname in tnames: u.report(tname)
410                         continue
411
412 def print_exception(ei, msgprefix=''):
413         if msgprefix: print >>sys.stderr, msgprefix
414         (et, q, tb) = ei
415         if et is Quit:
416                 print >>sys.stderr, 'adt-run:', q.m
417                 return q.ec
418         else:
419                 print >>sys.stderr, "adt-run: unexpected, exceptional, error:"
420                 traceback.print_exc()
421                 return 20
422
423 def cleanup():
424         try:
425                 rm_ec = 0
426                 if tmpdir is not None:
427                         rm_ec = subprocess.call(['rm','-rf','--',tmpdir])
428                 if testbed is not None:
429                         testbed.stop()
430                 if rm_ec: bomb('rm -rf -- %s failed, code %d' % (tmpdir, ec))
431         except:
432                 print_exception(sys.exc_info(),
433                         '\nadt-run: error cleaning up:\n')
434                 sys.exit(20)
435
436 def main():
437         global testbed
438         global tmpdir
439         try:
440                 parse_args()
441                 tmpdir = tempfile.mkdtemp()
442                 testbed = Testbed()
443                 testbed.start()
444                 read_control()
445                 run_tests()
446         except:
447                 ec = print_exception(sys.exc_info(), '')
448                 cleanup()
449                 sys.exit(ec)
450         cleanup()
451
452 main()