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