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