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