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