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