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