xref: /illumos-gate/usr/src/test/test-runner/cmd/run (revision eef4f27b270242808b43b4b23bd161df52839361)
1#!@PYTHON@
2
3#
4# This file and its contents are supplied under the terms of the
5# Common Development and Distribution License ("CDDL"), version 1.0.
6# You may only use this file in accordance with the terms of version
7# 1.0 of the CDDL.
8#
9# A full copy of the text of the CDDL should have accompanied this
10# source.  A copy of the CDDL is also available via the Internet at
11# http://www.illumos.org/license/CDDL.
12#
13
14#
15# Copyright (c) 2012, 2016 by Delphix. All rights reserved.
16# Copyright (c) 2017, Chris Fraire <cfraire@me.com>.
17# Copyright 2018 Joyent, Inc.
18#
19
20import ConfigParser
21import os
22import logging
23import platform
24from logging.handlers import WatchedFileHandler
25from datetime import datetime
26from optparse import OptionParser
27from pwd import getpwnam
28from pwd import getpwuid
29from select import select
30from subprocess import PIPE
31from subprocess import Popen
32from sys import argv
33from sys import maxint
34from threading import Timer
35from time import time
36
37BASEDIR = '/var/tmp/test_results'
38KILL = '/usr/bin/kill'
39TRUE = '/usr/bin/true'
40SUDO = '/usr/bin/sudo'
41
42# Custom class to reopen the log file in case it is forcibly closed by a test.
43class WatchedFileHandlerClosed(WatchedFileHandler):
44    """Watch files, including closed files.
45    Similar to (and inherits from) logging.handler.WatchedFileHandler,
46    except that IOErrors are handled by reopening the stream and retrying.
47    This will be retried up to a configurable number of times before
48    giving up, default 5.
49    """
50
51    def __init__(self, filename, mode='a', encoding=None, delay=0, max_tries=5):
52        self.max_tries = max_tries
53        self.tries = 0
54        WatchedFileHandler.__init__(self, filename, mode, encoding, delay)
55
56    def emit(self, record):
57        while True:
58            try:
59                WatchedFileHandler.emit(self, record)
60                self.tries = 0
61                return
62            except IOError as err:
63                if self.tries == self.max_tries:
64                    raise
65                self.stream.close()
66                self.stream = self._open()
67                self.tries += 1
68
69class Result(object):
70    total = 0
71    runresults = {'PASS': 0, 'FAIL': 0, 'SKIP': 0, 'KILLED': 0}
72
73    def __init__(self):
74        self.starttime = None
75        self.returncode = None
76        self.runtime = ''
77        self.stdout = []
78        self.stderr = []
79        self.result = ''
80
81    def done(self, proc, killed):
82        """
83        Finalize the results of this Cmd.
84	Report SKIP for return codes 3,4 (NOTINUSE, UNSUPPORTED)
85	as defined in ../stf/include/stf.shlib
86        """
87        Result.total += 1
88        m, s = divmod(time() - self.starttime, 60)
89        self.runtime = '%02d:%02d' % (m, s)
90        self.returncode = proc.returncode
91        if killed:
92            self.result = 'KILLED'
93            Result.runresults['KILLED'] += 1
94        elif self.returncode is 0:
95            self.result = 'PASS'
96            Result.runresults['PASS'] += 1
97        elif self.returncode is 3 or self.returncode is 4:
98            self.result = 'SKIP'
99            Result.runresults['SKIP'] += 1
100        elif self.returncode is not 0:
101            self.result = 'FAIL'
102            Result.runresults['FAIL'] += 1
103
104
105class Output(object):
106    """
107    This class is a slightly modified version of the 'Stream' class found
108    here: http://goo.gl/aSGfv
109    """
110    def __init__(self, stream):
111        self.stream = stream
112        self._buf = ''
113        self.lines = []
114
115    def fileno(self):
116        return self.stream.fileno()
117
118    def read(self, drain=0):
119        """
120        Read from the file descriptor. If 'drain' set, read until EOF.
121        """
122        while self._read() is not None:
123            if not drain:
124                break
125
126    def _read(self):
127        """
128        Read up to 4k of data from this output stream. Collect the output
129        up to the last newline, and append it to any leftover data from a
130        previous call. The lines are stored as a (timestamp, data) tuple
131        for easy sorting/merging later.
132        """
133        fd = self.fileno()
134        buf = os.read(fd, 4096)
135        if not buf:
136            return None
137        if '\n' not in buf:
138            self._buf += buf
139            return []
140
141        buf = self._buf + buf
142        tmp, rest = buf.rsplit('\n', 1)
143        self._buf = rest
144        now = datetime.now()
145        rows = tmp.split('\n')
146        self.lines += [(now, r) for r in rows]
147
148
149class Cmd(object):
150    verified_users = []
151
152    def __init__(self, pathname, outputdir=None, timeout=None, user=None):
153        self.pathname = pathname
154        self.outputdir = outputdir or 'BASEDIR'
155        self.timeout = timeout
156        self.user = user or ''
157        self.killed = False
158        self.result = Result()
159
160        if self.timeout is None:
161            self.timeout = 60
162
163    def __str__(self):
164        return "Pathname: %s\nOutputdir: %s\nTimeout: %s\nUser: %s\n" % \
165            (self.pathname, self.outputdir, self.timeout, self.user)
166
167    def kill_cmd(self, proc):
168        """
169        Kill a running command due to timeout, or ^C from the keyboard. If
170        sudo is required, this user was verified previously.
171        """
172        self.killed = True
173        do_sudo = len(self.user) != 0
174        signal = '-TERM'
175
176        cmd = [SUDO, KILL, signal, str(proc.pid)]
177        if not do_sudo:
178            del cmd[0]
179
180        try:
181            kp = Popen(cmd)
182            kp.wait()
183        except:
184            pass
185
186    def update_cmd_privs(self, cmd, user):
187        """
188        If a user has been specified to run this Cmd and we're not already
189        running as that user, prepend the appropriate sudo command to run
190        as that user.
191        """
192        me = getpwuid(os.getuid())
193
194        if not user or user is me:
195            return cmd
196
197        ret = '%s -E -u %s %s' % (SUDO, user, cmd)
198        return ret.split(' ')
199
200    def collect_output(self, proc):
201        """
202        Read from stdout/stderr as data becomes available, until the
203        process is no longer running. Return the lines from the stdout and
204        stderr Output objects.
205        """
206        out = Output(proc.stdout)
207        err = Output(proc.stderr)
208        res = []
209        while proc.returncode is None:
210            proc.poll()
211            res = select([out, err], [], [], .1)
212            for fd in res[0]:
213                fd.read()
214        for fd in res[0]:
215            fd.read(drain=1)
216
217        return out.lines, err.lines
218
219    def run(self, options):
220        """
221        This is the main function that runs each individual test.
222        Determine whether or not the command requires sudo, and modify it
223        if needed. Run the command, and update the result object.
224        """
225        if options.dryrun is True:
226            print self
227            return
228
229        privcmd = self.update_cmd_privs(self.pathname, self.user)
230        try:
231            old = os.umask(0)
232            if not os.path.isdir(self.outputdir):
233                os.makedirs(self.outputdir, mode=0777)
234            os.umask(old)
235        except OSError, e:
236            fail('%s' % e)
237
238        try:
239            self.result.starttime = time()
240            proc = Popen(privcmd, stdout=PIPE, stderr=PIPE, stdin=PIPE)
241            proc.stdin.close()
242
243            # Allow a special timeout value of 0 to mean infinity
244            if int(self.timeout) == 0:
245                self.timeout = maxint
246            t = Timer(int(self.timeout), self.kill_cmd, [proc])
247            t.start()
248            self.result.stdout, self.result.stderr = self.collect_output(proc)
249        except KeyboardInterrupt:
250            self.kill_cmd(proc)
251            fail('\nRun terminated at user request.')
252        finally:
253            t.cancel()
254
255        self.result.done(proc, self.killed)
256
257    def skip(self):
258        """
259        Initialize enough of the test result that we can log a skipped
260        command.
261        """
262        Result.total += 1
263        Result.runresults['SKIP'] += 1
264        self.result.stdout = self.result.stderr = []
265        self.result.starttime = time()
266        m, s = divmod(time() - self.result.starttime, 60)
267        self.result.runtime = '%02d:%02d' % (m, s)
268        self.result.result = 'SKIP'
269
270    def log(self, logger, options):
271        """
272        This function is responsible for writing all output. This includes
273        the console output, the logfile of all results (with timestamped
274        merged stdout and stderr), and for each test, the unmodified
275        stdout/stderr/merged in it's own file.
276        """
277        if logger is None:
278            return
279
280        logname = getpwuid(os.getuid()).pw_name
281        user = ' (run as %s)' % (self.user if len(self.user) else logname)
282        msga = 'Test: %s%s ' % (self.pathname, user)
283        msgb = '[%s] [%s]' % (self.result.runtime, self.result.result)
284        pad = ' ' * (80 - (len(msga) + len(msgb)))
285
286        # If -q is specified, only print a line for tests that didn't pass.
287        # This means passing tests need to be logged as DEBUG, or the one
288        # line summary will only be printed in the logfile for failures.
289        if not options.quiet:
290            logger.info('%s%s%s' % (msga, pad, msgb))
291        elif self.result.result is not 'PASS':
292            logger.info('%s%s%s' % (msga, pad, msgb))
293        else:
294            logger.debug('%s%s%s' % (msga, pad, msgb))
295
296        lines = sorted(self.result.stdout + self.result.stderr,
297                       cmp=lambda x, y: cmp(x[0], y[0]))
298
299        for dt, line in lines:
300            logger.debug('%s %s' % (dt.strftime("%H:%M:%S.%f ")[:11], line))
301
302        if len(self.result.stdout):
303            with open(os.path.join(self.outputdir, 'stdout'), 'w') as out:
304                for _, line in self.result.stdout:
305                    os.write(out.fileno(), '%s\n' % line)
306        if len(self.result.stderr):
307            with open(os.path.join(self.outputdir, 'stderr'), 'w') as err:
308                for _, line in self.result.stderr:
309                    os.write(err.fileno(), '%s\n' % line)
310        if len(self.result.stdout) and len(self.result.stderr):
311            with open(os.path.join(self.outputdir, 'merged'), 'w') as merged:
312                for _, line in lines:
313                    os.write(merged.fileno(), '%s\n' % line)
314
315
316class Test(Cmd):
317    props = ['outputdir', 'timeout', 'user', 'pre', 'pre_user', 'post',
318             'post_user']
319
320    def __init__(self, pathname, outputdir=None, timeout=None, user=None,
321                 pre=None, pre_user=None, post=None, post_user=None):
322        super(Test, self).__init__(pathname, outputdir, timeout, user)
323        self.pre = pre or ''
324        self.pre_user = pre_user or ''
325        self.post = post or ''
326        self.post_user = post_user or ''
327
328    def __str__(self):
329        post_user = pre_user = ''
330        if len(self.pre_user):
331            pre_user = ' (as %s)' % (self.pre_user)
332        if len(self.post_user):
333            post_user = ' (as %s)' % (self.post_user)
334        return "Pathname: %s\nOutputdir: %s\nTimeout: %d\nPre: %s%s\nPost: " \
335               "%s%s\nUser: %s\n" % \
336               (self.pathname, self.outputdir, self.timeout, self.pre,
337                pre_user, self.post, post_user, self.user)
338
339    def verify(self, logger):
340        """
341        Check the pre/post scripts, user and Test. Omit the Test from this
342        run if there are any problems.
343        """
344        files = [self.pre, self.pathname, self.post]
345        users = [self.pre_user, self.user, self.post_user]
346
347        for f in [f for f in files if len(f)]:
348            if not verify_file(f):
349                logger.info("Warning: Test '%s' not added to this run because"
350                            " it failed verification." % f)
351                return False
352
353        for user in [user for user in users if len(user)]:
354            if not verify_user(user, logger):
355                logger.info("Not adding Test '%s' to this run." %
356                            self.pathname)
357                return False
358
359        return True
360
361    def run(self, logger, options):
362        """
363        Create Cmd instances for the pre/post scripts. If the pre script
364        doesn't pass, skip this Test. Run the post script regardless.
365        """
366        odir = os.path.join(self.outputdir, os.path.basename(self.pre))
367        pretest = Cmd(self.pre, outputdir=odir, timeout=self.timeout,
368                      user=self.pre_user)
369        test = Cmd(self.pathname, outputdir=self.outputdir,
370                   timeout=self.timeout, user=self.user)
371        odir = os.path.join(self.outputdir, os.path.basename(self.post))
372        posttest = Cmd(self.post, outputdir=odir, timeout=self.timeout,
373                       user=self.post_user)
374
375        cont = True
376        if len(pretest.pathname):
377            pretest.run(options)
378            cont = pretest.result.result is 'PASS'
379            pretest.log(logger, options)
380
381        if cont:
382            test.run(options)
383        else:
384            test.skip()
385
386        test.log(logger, options)
387
388        if len(posttest.pathname):
389            posttest.run(options)
390            posttest.log(logger, options)
391
392
393class TestGroup(Test):
394    props = Test.props + ['tests']
395
396    def __init__(self, pathname, outputdir=None, timeout=None, user=None,
397                 pre=None, pre_user=None, post=None, post_user=None,
398                 tests=None):
399        super(TestGroup, self).__init__(pathname, outputdir, timeout, user,
400                                        pre, pre_user, post, post_user)
401        self.tests = tests or []
402
403    def __str__(self):
404        post_user = pre_user = ''
405        if len(self.pre_user):
406            pre_user = ' (as %s)' % (self.pre_user)
407        if len(self.post_user):
408            post_user = ' (as %s)' % (self.post_user)
409        return "Pathname: %s\nOutputdir: %s\nTests: %s\nTimeout: %d\n" \
410               "Pre: %s%s\nPost: %s%s\nUser: %s\n" % \
411               (self.pathname, self.outputdir, self.tests, self.timeout,
412                self.pre, pre_user, self.post, post_user, self.user)
413
414    def verify(self, logger):
415        """
416        Check the pre/post scripts, user and tests in this TestGroup. Omit
417        the TestGroup entirely, or simply delete the relevant tests in the
418        group, if that's all that's required.
419        """
420        # If the pre or post scripts are relative pathnames, convert to
421        # absolute, so they stand a chance of passing verification.
422        if len(self.pre) and not os.path.isabs(self.pre):
423            self.pre = os.path.join(self.pathname, self.pre)
424        if len(self.post) and not os.path.isabs(self.post):
425            self.post = os.path.join(self.pathname, self.post)
426
427        auxfiles = [self.pre, self.post]
428        users = [self.pre_user, self.user, self.post_user]
429
430        for f in [f for f in auxfiles if len(f)]:
431            if self.pathname != os.path.dirname(f):
432                logger.info("Warning: TestGroup '%s' not added to this run. "
433                            "Auxiliary script '%s' exists in a different "
434                            "directory." % (self.pathname, f))
435                return False
436
437            if not verify_file(f):
438                logger.info("Warning: TestGroup '%s' not added to this run. "
439                            "Auxiliary script '%s' failed verification." %
440                            (self.pathname, f))
441                return False
442
443        for user in [user for user in users if len(user)]:
444            if not verify_user(user, logger):
445                logger.info("Not adding TestGroup '%s' to this run." %
446                            self.pathname)
447                return False
448
449        # If one of the tests is invalid, delete it, log it, and drive on.
450        self.tests[:] = [f for f in self.tests if
451          verify_file(os.path.join(self.pathname, f))]
452
453        return len(self.tests) is not 0
454
455    def run(self, logger, options):
456        """
457        Create Cmd instances for the pre/post scripts. If the pre script
458        doesn't pass, skip all the tests in this TestGroup. Run the post
459        script regardless.
460        """
461        odir = os.path.join(self.outputdir, os.path.basename(self.pre))
462        pretest = Cmd(self.pre, outputdir=odir, timeout=self.timeout,
463                      user=self.pre_user)
464        odir = os.path.join(self.outputdir, os.path.basename(self.post))
465        posttest = Cmd(self.post, outputdir=odir, timeout=self.timeout,
466                       user=self.post_user)
467
468        cont = True
469        if len(pretest.pathname):
470            pretest.run(options)
471            cont = pretest.result.result is 'PASS'
472            pretest.log(logger, options)
473
474        for fname in self.tests:
475            test = Cmd(os.path.join(self.pathname, fname),
476                       outputdir=os.path.join(self.outputdir, fname),
477                       timeout=self.timeout, user=self.user)
478            if cont:
479                test.run(options)
480            else:
481                test.skip()
482
483            test.log(logger, options)
484
485        if len(posttest.pathname):
486            posttest.run(options)
487            posttest.log(logger, options)
488
489
490class TestRun(object):
491    props = ['quiet', 'outputdir']
492
493    def __init__(self, options):
494        self.tests = {}
495        self.testgroups = {}
496        self.starttime = time()
497        self.timestamp = datetime.now().strftime('%Y%m%dT%H%M%S')
498        self.outputdir = os.path.join(options.outputdir, self.timestamp)
499        self.logger = self.setup_logging(options)
500        self.defaults = [
501            ('outputdir', BASEDIR),
502            ('quiet', False),
503            ('timeout', 60),
504            ('user', ''),
505            ('pre', ''),
506            ('pre_user', ''),
507            ('post', ''),
508            ('post_user', '')
509        ]
510
511    def __str__(self):
512        s = 'TestRun:\n    outputdir: %s\n' % self.outputdir
513        s += 'TESTS:\n'
514        for key in sorted(self.tests.keys()):
515            s += '%s%s' % (self.tests[key].__str__(), '\n')
516        s += 'TESTGROUPS:\n'
517        for key in sorted(self.testgroups.keys()):
518            s += '%s%s' % (self.testgroups[key].__str__(), '\n')
519        return s
520
521    def addtest(self, pathname, options):
522        """
523        Create a new Test, and apply any properties that were passed in
524        from the command line. If it passes verification, add it to the
525        TestRun.
526        """
527        test = Test(pathname)
528        for prop in Test.props:
529            setattr(test, prop, getattr(options, prop))
530
531        if test.verify(self.logger):
532            self.tests[pathname] = test
533
534    def addtestgroup(self, dirname, filenames, options):
535        """
536        Create a new TestGroup, and apply any properties that were passed
537        in from the command line. If it passes verification, add it to the
538        TestRun.
539        """
540        if dirname not in self.testgroups:
541            testgroup = TestGroup(dirname)
542            for prop in Test.props:
543                setattr(testgroup, prop, getattr(options, prop))
544
545            # Prevent pre/post scripts from running as regular tests
546            for f in [testgroup.pre, testgroup.post]:
547                if f in filenames:
548                    del filenames[filenames.index(f)]
549
550            self.testgroups[dirname] = testgroup
551            self.testgroups[dirname].tests = sorted(filenames)
552
553            testgroup.verify(self.logger)
554
555    def read(self, logger, options):
556        """
557        Read in the specified runfile, and apply the TestRun properties
558        listed in the 'DEFAULT' section to our TestRun. Then read each
559        section, and apply the appropriate properties to the Test or
560        TestGroup. Properties from individual sections override those set
561        in the 'DEFAULT' section. If the Test or TestGroup passes
562        verification, add it to the TestRun.
563        """
564        config = ConfigParser.RawConfigParser()
565        if not len(config.read(options.runfile)):
566            fail("Coulnd't read config file %s" % options.runfile)
567
568        for opt in TestRun.props:
569            if config.has_option('DEFAULT', opt):
570                setattr(self, opt, config.get('DEFAULT', opt))
571        self.outputdir = os.path.join(self.outputdir, self.timestamp)
572
573        for section in config.sections():
574            if ('arch' in config.options(section) and
575                platform.machine() != config.get(section, 'arch')):
576                continue
577
578            if 'tests' in config.options(section):
579                testgroup = TestGroup(section)
580                for prop in TestGroup.props:
581                    for sect in ['DEFAULT', section]:
582                        if config.has_option(sect, prop):
583                            setattr(testgroup, prop, config.get(sect, prop))
584
585                # Repopulate tests using eval to convert the string to a list
586                testgroup.tests = eval(config.get(section, 'tests'))
587
588                if testgroup.verify(logger):
589                    self.testgroups[section] = testgroup
590
591            elif 'autotests' in config.options(section):
592                testgroup = TestGroup(section)
593                for prop in TestGroup.props:
594                    for sect in ['DEFAULT', section]:
595                        if config.has_option(sect, prop):
596                            setattr(testgroup, prop, config.get(sect, prop))
597
598                filenames = os.listdir(section)
599                # only files starting with "tst." are considered tests
600                filenames = [f for f in filenames if f.startswith("tst.")]
601                testgroup.tests = sorted(filenames)
602
603                if testgroup.verify(logger):
604                    self.testgroups[section] = testgroup
605
606            else:
607                test = Test(section)
608                for prop in Test.props:
609                    for sect in ['DEFAULT', section]:
610                        if config.has_option(sect, prop):
611                            setattr(test, prop, config.get(sect, prop))
612
613                if test.verify(logger):
614                    self.tests[section] = test
615
616    def write(self, options):
617        """
618        Create a configuration file for editing and later use. The
619        'DEFAULT' section of the config file is created from the
620        properties that were specified on the command line. Tests are
621        simply added as sections that inherit everything from the
622        'DEFAULT' section. TestGroups are the same, except they get an
623        option including all the tests to run in that directory.
624        """
625
626        defaults = dict([(prop, getattr(options, prop)) for prop, _ in
627                         self.defaults])
628        config = ConfigParser.RawConfigParser(defaults)
629
630        for test in sorted(self.tests.keys()):
631            config.add_section(test)
632
633        for testgroup in sorted(self.testgroups.keys()):
634            config.add_section(testgroup)
635            config.set(testgroup, 'tests', self.testgroups[testgroup].tests)
636
637        try:
638            with open(options.template, 'w') as f:
639                return config.write(f)
640        except IOError:
641            fail('Could not open \'%s\' for writing.' % options.template)
642
643    def complete_outputdirs(self):
644        """
645        Collect all the pathnames for Tests, and TestGroups. Work
646        backwards one pathname component at a time, to create a unique
647        directory name in which to deposit test output. Tests will be able
648        to write output files directly in the newly modified outputdir.
649        TestGroups will be able to create one subdirectory per test in the
650        outputdir, and are guaranteed uniqueness because a group can only
651        contain files in one directory. Pre and post tests will create a
652        directory rooted at the outputdir of the Test or TestGroup in
653        question for their output.
654        """
655        done = False
656        components = 0
657        tmp_dict = dict(self.tests.items() + self.testgroups.items())
658        total = len(tmp_dict)
659        base = self.outputdir
660
661        while not done:
662            l = []
663            components -= 1
664            for testfile in tmp_dict.keys():
665                uniq = '/'.join(testfile.split('/')[components:]).lstrip('/')
666                if uniq not in l:
667                    l.append(uniq)
668                    tmp_dict[testfile].outputdir = os.path.join(base, uniq)
669                else:
670                    break
671            done = total == len(l)
672
673    def setup_logging(self, options):
674        """
675        Two loggers are set up here. The first is for the logfile which
676        will contain one line summarizing the test, including the test
677        name, result, and running time. This logger will also capture the
678        timestamped combined stdout and stderr of each run. The second
679        logger is optional console output, which will contain only the one
680        line summary. The loggers are initialized at two different levels
681        to facilitate segregating the output.
682        """
683        if options.dryrun is True:
684            return
685
686        testlogger = logging.getLogger(__name__)
687        testlogger.setLevel(logging.DEBUG)
688
689        if options.cmd is not 'wrconfig':
690            try:
691                old = os.umask(0)
692                os.makedirs(self.outputdir, mode=0777)
693                os.umask(old)
694            except OSError, e:
695                fail('%s' % e)
696            filename = os.path.join(self.outputdir, 'log')
697
698            logfile = WatchedFileHandlerClosed(filename)
699            logfile.setLevel(logging.DEBUG)
700            logfilefmt = logging.Formatter('%(message)s')
701            logfile.setFormatter(logfilefmt)
702            testlogger.addHandler(logfile)
703
704        cons = logging.StreamHandler()
705        cons.setLevel(logging.INFO)
706        consfmt = logging.Formatter('%(message)s')
707        cons.setFormatter(consfmt)
708        testlogger.addHandler(cons)
709
710        return testlogger
711
712    def run(self, options):
713        """
714        Walk through all the Tests and TestGroups, calling run().
715        """
716        if not options.dryrun:
717            try:
718                os.chdir(self.outputdir)
719            except OSError:
720                fail('Could not change to directory %s' % self.outputdir)
721        for test in sorted(self.tests.keys()):
722            self.tests[test].run(self.logger, options)
723        for testgroup in sorted(self.testgroups.keys()):
724            self.testgroups[testgroup].run(self.logger, options)
725
726    def summary(self):
727        if Result.total is 0:
728            return
729
730        print '\nResults Summary'
731        for key in Result.runresults.keys():
732            if Result.runresults[key] is not 0:
733                print '%s\t% 4d' % (key, Result.runresults[key])
734
735        m, s = divmod(time() - self.starttime, 60)
736        h, m = divmod(m, 60)
737        print '\nRunning Time:\t%02d:%02d:%02d' % (h, m, s)
738        print 'Percent passed:\t%.1f%%' % ((float(Result.runresults['PASS']) /
739                                            float(Result.total)) * 100)
740        print 'Log directory:\t%s' % self.outputdir
741
742
743def verify_file(pathname):
744    """
745    Verify that the supplied pathname is an executable regular file.
746    """
747    if os.path.isdir(pathname) or os.path.islink(pathname):
748        return False
749
750    if os.path.isfile(pathname) and os.access(pathname, os.X_OK):
751        return True
752
753    return False
754
755
756def verify_user(user, logger):
757    """
758    Verify that the specified user exists on this system, and can execute
759    sudo without being prompted for a password.
760    """
761    testcmd = [SUDO, '-n', '-u', user, TRUE]
762
763    if user in Cmd.verified_users:
764        return True
765
766    try:
767        _ = getpwnam(user)
768    except KeyError:
769        logger.info("Warning: user '%s' does not exist.", user)
770        return False
771
772    p = Popen(testcmd)
773    p.wait()
774    if p.returncode is not 0:
775        logger.info("Warning: user '%s' cannot use passwordless sudo.", user)
776        return False
777    else:
778        Cmd.verified_users.append(user)
779
780    return True
781
782
783def find_tests(testrun, options):
784    """
785    For the given list of pathnames, add files as Tests. For directories,
786    if do_groups is True, add the directory as a TestGroup. If False,
787    recursively search for executable files.
788    """
789
790    for p in sorted(options.pathnames):
791        if os.path.isdir(p):
792            for dirname, _, filenames in os.walk(p):
793                if options.do_groups:
794                    testrun.addtestgroup(dirname, filenames, options)
795                else:
796                    for f in sorted(filenames):
797                        testrun.addtest(os.path.join(dirname, f), options)
798        else:
799            testrun.addtest(p, options)
800
801
802def fail(retstr, ret=1):
803    print '%s: %s' % (argv[0], retstr)
804    exit(ret)
805
806
807def options_cb(option, opt_str, value, parser):
808    path_options = ['runfile', 'outputdir', 'template']
809
810    if option.dest is 'runfile' and '-w' in parser.rargs or \
811            option.dest is 'template' and '-c' in parser.rargs:
812        fail('-c and -w are mutually exclusive.')
813
814    if opt_str in parser.rargs:
815        fail('%s may only be specified once.' % opt_str)
816
817    if option.dest is 'runfile':
818        parser.values.cmd = 'rdconfig'
819    if option.dest is 'template':
820        parser.values.cmd = 'wrconfig'
821
822    setattr(parser.values, option.dest, value)
823    if option.dest in path_options:
824        setattr(parser.values, option.dest, os.path.abspath(value))
825
826
827def parse_args():
828    parser = OptionParser()
829    parser.add_option('-c', action='callback', callback=options_cb,
830                      type='string', dest='runfile', metavar='runfile',
831                      help='Specify tests to run via config file.')
832    parser.add_option('-d', action='store_true', default=False, dest='dryrun',
833                      help='Dry run. Print tests, but take no other action.')
834    parser.add_option('-g', action='store_true', default=False,
835                      dest='do_groups', help='Make directories TestGroups.')
836    parser.add_option('-o', action='callback', callback=options_cb,
837                      default=BASEDIR, dest='outputdir', type='string',
838                      metavar='outputdir', help='Specify an output directory.')
839    parser.add_option('-p', action='callback', callback=options_cb,
840                      default='', dest='pre', metavar='script',
841                      type='string', help='Specify a pre script.')
842    parser.add_option('-P', action='callback', callback=options_cb,
843                      default='', dest='post', metavar='script',
844                      type='string', help='Specify a post script.')
845    parser.add_option('-q', action='store_true', default=False, dest='quiet',
846                      help='Silence on the console during a test run.')
847    parser.add_option('-t', action='callback', callback=options_cb, default=60,
848                      dest='timeout', metavar='seconds', type='int',
849                      help='Timeout (in seconds) for an individual test.')
850    parser.add_option('-u', action='callback', callback=options_cb,
851                      default='', dest='user', metavar='user', type='string',
852                      help='Specify a different user name to run as.')
853    parser.add_option('-w', action='callback', callback=options_cb,
854                      default=None, dest='template', metavar='template',
855                      type='string', help='Create a new config file.')
856    parser.add_option('-x', action='callback', callback=options_cb, default='',
857                      dest='pre_user', metavar='pre_user', type='string',
858                      help='Specify a user to execute the pre script.')
859    parser.add_option('-X', action='callback', callback=options_cb, default='',
860                      dest='post_user', metavar='post_user', type='string',
861                      help='Specify a user to execute the post script.')
862    (options, pathnames) = parser.parse_args()
863
864    if not options.runfile and not options.template:
865        options.cmd = 'runtests'
866
867    if options.runfile and len(pathnames):
868        fail('Extraneous arguments.')
869
870    options.pathnames = [os.path.abspath(path) for path in pathnames]
871
872    return options
873
874
875def main():
876    options = parse_args()
877    testrun = TestRun(options)
878
879    if options.cmd is 'runtests':
880        find_tests(testrun, options)
881    elif options.cmd is 'rdconfig':
882        testrun.read(testrun.logger, options)
883    elif options.cmd is 'wrconfig':
884        find_tests(testrun, options)
885        testrun.write(options)
886        exit(0)
887    else:
888        fail('Unknown command specified')
889
890    testrun.complete_outputdirs()
891    testrun.run(options)
892    testrun.summary()
893    exit(0)
894
895
896if __name__ == '__main__':
897    main()
898