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
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.
15 # Copyright (c) 2012, 2015 by Delphix. All rights reserved.
21 from logging.handlers import WatchedFileHandler
22 from datetime import datetime
23 from optparse import OptionParser
24 from pwd import getpwnam
25 from pwd import getpwuid
26 from select import select
27 from subprocess import PIPE
28 from subprocess import Popen
30 from sys import maxint
31 from threading import Timer
34 BASEDIR = '/var/tmp/test_results'
35 KILL = '/usr/bin/kill'
36 TRUE = '/usr/bin/true'
37 SUDO = '/usr/bin/sudo'
39 # Custom class to reopen the log file in case it is forcibly closed by a test.
40 class 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
48 def __init__(self, filename, mode='a', encoding=None, delay=0, max_tries=5):
49 self.max_tries = max_tries
51 WatchedFileHandler.__init__(self, filename, mode, encoding, delay)
53 def emit(self, record):
56 WatchedFileHandler.emit(self, record)
59 except IOError as err:
60 if self.tries == self.max_tries:
63 self.stream = self._open()
68 runresults = {'PASS': 0, 'FAIL': 0, 'SKIP': 0, 'KILLED': 0}
72 self.returncode = None
78 def done(self, proc, killed):
80 Finalize the results of this Cmd.
83 m, s = divmod(time() - self.starttime, 60)
84 self.runtime = '%02d:%02d' % (m, s)
85 self.returncode = proc.returncode
87 self.result = 'KILLED'
88 Result.runresults['KILLED'] += 1
89 elif self.returncode is 0:
91 Result.runresults['PASS'] += 1
92 elif self.returncode is not 0:
94 Result.runresults['FAIL'] += 1
99 This class is a slightly modified version of the 'Stream' class found
100 here: http://goo.gl/aSGfv
102 def __init__(self, stream):
108 return self.stream.fileno()
110 def read(self, drain=0):
112 Read from the file descriptor. If 'drain' set, read until EOF.
114 while self._read() is not None:
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.
126 buf = os.read(fd, 4096)
133 buf = self._buf + buf
134 tmp, rest = buf.rsplit('\n', 1)
137 rows = tmp.split('\n')
138 self.lines += [(now, r) for r in rows]
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 ''
150 self.result = Result()
152 if self.timeout is None:
156 return "Pathname: %s\nOutputdir: %s\nTimeout: %d\nUser: %s\n" % \
157 (self.pathname, self.outputdir, self.timeout, self.user)
159 def kill_cmd(self, proc):
161 Kill a running command due to timeout, or ^C from the keyboard. If
162 sudo is required, this user was verified previously.
165 do_sudo = len(self.user) != 0
168 cmd = [SUDO, KILL, signal, str(proc.pid)]
178 def update_cmd_privs(self, cmd, user):
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
184 me = getpwuid(os.getuid())
186 if not user or user is me:
189 ret = '%s -E -u %s %s' % (SUDO, user, cmd)
190 return ret.split(' ')
192 def collect_output(self, proc):
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.
198 out = Output(proc.stdout)
199 err = Output(proc.stderr)
201 while proc.returncode is None:
203 res = select([out, err], [], [], .1)
209 return out.lines, err.lines
211 def run(self, options):
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.
217 if options.dryrun is True:
221 privcmd = self.update_cmd_privs(self.pathname, self.user)
224 if not os.path.isdir(self.outputdir):
225 os.makedirs(self.outputdir, mode=0777)
231 self.result.starttime = time()
232 proc = Popen(privcmd, stdout=PIPE, stderr=PIPE)
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])
239 self.result.stdout, self.result.stderr = self.collect_output(proc)
240 except KeyboardInterrupt:
242 fail('\nRun terminated at user request.')
246 self.result.done(proc, self.killed)
250 Initialize enough of the test result that we can log a skipped
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'
261 def log(self, logger, options):
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.
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)))
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))
285 logger.debug('%s%s%s' % (msga, pad, msgb))
287 lines = sorted(self.result.stdout + self.result.stderr,
288 cmp=lambda x, y: cmp(x[0], y[0]))
290 for dt, line in lines:
291 logger.debug('%s %s' % (dt.strftime("%H:%M:%S.%f ")[:11], line))
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)
308 props = ['outputdir', 'timeout', 'user', 'pre', 'pre_user', 'post',
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)
315 self.pre_user = pre_user or ''
316 self.post = post or ''
317 self.post_user = post_user or ''
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)
330 def verify(self, logger):
332 Check the pre/post scripts, user and Test. Omit the Test from this
333 run if there are any problems.
335 files = [self.pre, self.pathname, self.post]
336 users = [self.pre_user, self.user, self.post_user]
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)
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." %
352 def run(self, logger, options):
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.
357 odir = os.path.join(self.outputdir, os.path.basename(self.pre))
358 pretest = Cmd(self.pre, outputdir=odir, timeout=self.timeout,
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,
367 if len(pretest.pathname):
369 cont = pretest.result.result is 'PASS'
370 pretest.log(logger, options)
377 test.log(logger, options)
379 if len(posttest.pathname):
380 posttest.run(options)
381 posttest.log(logger, options)
384 class TestGroup(Test):
385 props = Test.props + ['tests']
387 def __init__(self, pathname, outputdir=None, timeout=None, user=None,
388 pre=None, pre_user=None, post=None, post_user=None,
390 super(TestGroup, self).__init__(pathname, outputdir, timeout, user,
391 pre, pre_user, post, post_user)
392 self.tests = tests or []
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)
405 def verify(self, logger):
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.
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)
418 auxfiles = [self.pre, self.post]
419 users = [self.pre_user, self.user, self.post_user]
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))
428 if not verify_file(f):
429 logger.info("Warning: TestGroup '%s' not added to this run. "
430 "Auxiliary script '%s' failed verification." %
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." %
440 # If one of the tests is invalid, delete it, log it, and drive on.
441 for test in self.tests:
442 if not verify_file(os.path.join(self.pathname, test)):
443 del self.tests[self.tests.index(test)]
444 logger.info("Warning: Test '%s' removed from TestGroup '%s' "
445 "because it failed verification." %
446 (test, self.pathname))
448 return len(self.tests) is not 0
450 def run(self, logger, options):
452 Create Cmd instances for the pre/post scripts. If the pre script
453 doesn't pass, skip all the tests in this TestGroup. Run the post
456 odir = os.path.join(self.outputdir, os.path.basename(self.pre))
457 pretest = Cmd(self.pre, outputdir=odir, timeout=self.timeout,
459 odir = os.path.join(self.outputdir, os.path.basename(self.post))
460 posttest = Cmd(self.post, outputdir=odir, timeout=self.timeout,
464 if len(pretest.pathname):
466 cont = pretest.result.result is 'PASS'
467 pretest.log(logger, options)
469 for fname in self.tests:
470 test = Cmd(os.path.join(self.pathname, fname),
471 outputdir=os.path.join(self.outputdir, fname),
472 timeout=self.timeout, user=self.user)
478 test.log(logger, options)
480 if len(posttest.pathname):
481 posttest.run(options)
482 posttest.log(logger, options)
485 class TestRun(object):
486 props = ['quiet', 'outputdir']
488 def __init__(self, options):
491 self.starttime = time()
492 self.timestamp = datetime.now().strftime('%Y%m%dT%H%M%S')
493 self.outputdir = os.path.join(options.outputdir, self.timestamp)
494 self.logger = self.setup_logging(options)
496 ('outputdir', BASEDIR),
507 s = 'TestRun:\n outputdir: %s\n' % self.outputdir
509 for key in sorted(self.tests.keys()):
510 s += '%s%s' % (self.tests[key].__str__(), '\n')
512 for key in sorted(self.testgroups.keys()):
513 s += '%s%s' % (self.testgroups[key].__str__(), '\n')
516 def addtest(self, pathname, options):
518 Create a new Test, and apply any properties that were passed in
519 from the command line. If it passes verification, add it to the
522 test = Test(pathname)
523 for prop in Test.props:
524 setattr(test, prop, getattr(options, prop))
526 if test.verify(self.logger):
527 self.tests[pathname] = test
529 def addtestgroup(self, dirname, filenames, options):
531 Create a new TestGroup, and apply any properties that were passed
532 in from the command line. If it passes verification, add it to the
535 if dirname not in self.testgroups:
536 testgroup = TestGroup(dirname)
537 for prop in Test.props:
538 setattr(testgroup, prop, getattr(options, prop))
540 # Prevent pre/post scripts from running as regular tests
541 for f in [testgroup.pre, testgroup.post]:
543 del filenames[filenames.index(f)]
545 self.testgroups[dirname] = testgroup
546 self.testgroups[dirname].tests = sorted(filenames)
548 testgroup.verify(self.logger)
550 def read(self, logger, options):
552 Read in the specified runfile, and apply the TestRun properties
553 listed in the 'DEFAULT' section to our TestRun. Then read each
554 section, and apply the appropriate properties to the Test or
555 TestGroup. Properties from individual sections override those set
556 in the 'DEFAULT' section. If the Test or TestGroup passes
557 verification, add it to the TestRun.
559 config = ConfigParser.RawConfigParser()
560 if not len(config.read(options.runfile)):
561 fail("Coulnd't read config file %s" % options.runfile)
563 for opt in TestRun.props:
564 if config.has_option('DEFAULT', opt):
565 setattr(self, opt, config.get('DEFAULT', opt))
566 self.outputdir = os.path.join(self.outputdir, self.timestamp)
568 for section in config.sections():
569 if 'tests' in config.options(section):
570 testgroup = TestGroup(section)
571 for prop in TestGroup.props:
572 for sect in ['DEFAULT', section]:
573 if config.has_option(sect, prop):
574 setattr(testgroup, prop, config.get(sect, prop))
576 # Repopulate tests using eval to convert the string to a list
577 testgroup.tests = eval(config.get(section, 'tests'))
579 if testgroup.verify(logger):
580 self.testgroups[section] = testgroup
583 for prop in Test.props:
584 for sect in ['DEFAULT', section]:
585 if config.has_option(sect, prop):
586 setattr(test, prop, config.get(sect, prop))
588 if test.verify(logger):
589 self.tests[section] = test
591 def write(self, options):
593 Create a configuration file for editing and later use. The
594 'DEFAULT' section of the config file is created from the
595 properties that were specified on the command line. Tests are
596 simply added as sections that inherit everything from the
597 'DEFAULT' section. TestGroups are the same, except they get an
598 option including all the tests to run in that directory.
601 defaults = dict([(prop, getattr(options, prop)) for prop, _ in
603 config = ConfigParser.RawConfigParser(defaults)
605 for test in sorted(self.tests.keys()):
606 config.add_section(test)
608 for testgroup in sorted(self.testgroups.keys()):
609 config.add_section(testgroup)
610 config.set(testgroup, 'tests', self.testgroups[testgroup].tests)
613 with open(options.template, 'w') as f:
614 return config.write(f)
616 fail('Could not open \'%s\' for writing.' % options.template)
618 def complete_outputdirs(self):
620 Collect all the pathnames for Tests, and TestGroups. Work
621 backwards one pathname component at a time, to create a unique
622 directory name in which to deposit test output. Tests will be able
623 to write output files directly in the newly modified outputdir.
624 TestGroups will be able to create one subdirectory per test in the
625 outputdir, and are guaranteed uniqueness because a group can only
626 contain files in one directory. Pre and post tests will create a
627 directory rooted at the outputdir of the Test or TestGroup in
628 question for their output.
632 tmp_dict = dict(self.tests.items() + self.testgroups.items())
633 total = len(tmp_dict)
634 base = self.outputdir
639 for testfile in tmp_dict.keys():
640 uniq = '/'.join(testfile.split('/')[components:]).lstrip('/')
643 tmp_dict[testfile].outputdir = os.path.join(base, uniq)
646 done = total == len(l)
648 def setup_logging(self, options):
650 Two loggers are set up here. The first is for the logfile which
651 will contain one line summarizing the test, including the test
652 name, result, and running time. This logger will also capture the
653 timestamped combined stdout and stderr of each run. The second
654 logger is optional console output, which will contain only the one
655 line summary. The loggers are initialized at two different levels
656 to facilitate segregating the output.
658 if options.dryrun is True:
661 testlogger = logging.getLogger(__name__)
662 testlogger.setLevel(logging.DEBUG)
664 if options.cmd is not 'wrconfig':
667 os.makedirs(self.outputdir, mode=0777)
671 filename = os.path.join(self.outputdir, 'log')
673 logfile = WatchedFileHandlerClosed(filename)
674 logfile.setLevel(logging.DEBUG)
675 logfilefmt = logging.Formatter('%(message)s')
676 logfile.setFormatter(logfilefmt)
677 testlogger.addHandler(logfile)
679 cons = logging.StreamHandler()
680 cons.setLevel(logging.INFO)
681 consfmt = logging.Formatter('%(message)s')
682 cons.setFormatter(consfmt)
683 testlogger.addHandler(cons)
687 def run(self, options):
689 Walk through all the Tests and TestGroups, calling run().
692 os.chdir(self.outputdir)
694 fail('Could not change to directory %s' % self.outputdir)
695 for test in sorted(self.tests.keys()):
696 self.tests[test].run(self.logger, options)
697 for testgroup in sorted(self.testgroups.keys()):
698 self.testgroups[testgroup].run(self.logger, options)
701 if Result.total is 0:
704 print '\nResults Summary'
705 for key in Result.runresults.keys():
706 if Result.runresults[key] is not 0:
707 print '%s\t% 4d' % (key, Result.runresults[key])
709 m, s = divmod(time() - self.starttime, 60)
711 print '\nRunning Time:\t%02d:%02d:%02d' % (h, m, s)
712 print 'Percent passed:\t%.1f%%' % ((float(Result.runresults['PASS']) /
713 float(Result.total)) * 100)
714 print 'Log directory:\t%s' % self.outputdir
717 def verify_file(pathname):
719 Verify that the supplied pathname is an executable regular file.
721 if os.path.isdir(pathname) or os.path.islink(pathname):
724 if os.path.isfile(pathname) and os.access(pathname, os.X_OK):
730 def verify_user(user, logger):
732 Verify that the specified user exists on this system, and can execute
733 sudo without being prompted for a password.
735 testcmd = [SUDO, '-n', '-u', user, TRUE]
737 if user in Cmd.verified_users:
743 logger.info("Warning: user '%s' does not exist.", user)
748 if p.returncode is not 0:
749 logger.info("Warning: user '%s' cannot use passwordless sudo.", user)
752 Cmd.verified_users.append(user)
757 def find_tests(testrun, options):
759 For the given list of pathnames, add files as Tests. For directories,
760 if do_groups is True, add the directory as a TestGroup. If False,
761 recursively search for executable files.
764 for p in sorted(options.pathnames):
766 for dirname, _, filenames in os.walk(p):
767 if options.do_groups:
768 testrun.addtestgroup(dirname, filenames, options)
770 for f in sorted(filenames):
771 testrun.addtest(os.path.join(dirname, f), options)
773 testrun.addtest(p, options)
776 def fail(retstr, ret=1):
777 print '%s: %s' % (argv[0], retstr)
781 def options_cb(option, opt_str, value, parser):
782 path_options = ['runfile', 'outputdir', 'template']
784 if option.dest is 'runfile' and '-w' in parser.rargs or \
785 option.dest is 'template' and '-c' in parser.rargs:
786 fail('-c and -w are mutually exclusive.')
788 if opt_str in parser.rargs:
789 fail('%s may only be specified once.' % opt_str)
791 if option.dest is 'runfile':
792 parser.values.cmd = 'rdconfig'
793 if option.dest is 'template':
794 parser.values.cmd = 'wrconfig'
796 setattr(parser.values, option.dest, value)
797 if option.dest in path_options:
798 setattr(parser.values, option.dest, os.path.abspath(value))
802 parser = OptionParser()
803 parser.add_option('-c', action='callback', callback=options_cb,
804 type='string', dest='runfile', metavar='runfile',
805 help='Specify tests to run via config file.')
806 parser.add_option('-d', action='store_true', default=False, dest='dryrun',
807 help='Dry run. Print tests, but take no other action.')
808 parser.add_option('-g', action='store_true', default=False,
809 dest='do_groups', help='Make directories TestGroups.')
810 parser.add_option('-o', action='callback', callback=options_cb,
811 default=BASEDIR, dest='outputdir', type='string',
812 metavar='outputdir', help='Specify an output directory.')
813 parser.add_option('-p', action='callback', callback=options_cb,
814 default='', dest='pre', metavar='script',
815 type='string', help='Specify a pre script.')
816 parser.add_option('-P', action='callback', callback=options_cb,
817 default='', dest='post', metavar='script',
818 type='string', help='Specify a post script.')
819 parser.add_option('-q', action='store_true', default=False, dest='quiet',
820 help='Silence on the console during a test run.')
821 parser.add_option('-t', action='callback', callback=options_cb, default=60,
822 dest='timeout', metavar='seconds', type='int',
823 help='Timeout (in seconds) for an individual test.')
824 parser.add_option('-u', action='callback', callback=options_cb,
825 default='', dest='user', metavar='user', type='string',
826 help='Specify a different user name to run as.')
827 parser.add_option('-w', action='callback', callback=options_cb,
828 default=None, dest='template', metavar='template',
829 type='string', help='Create a new config file.')
830 parser.add_option('-x', action='callback', callback=options_cb, default='',
831 dest='pre_user', metavar='pre_user', type='string',
832 help='Specify a user to execute the pre script.')
833 parser.add_option('-X', action='callback', callback=options_cb, default='',
834 dest='post_user', metavar='post_user', type='string',
835 help='Specify a user to execute the post script.')
836 (options, pathnames) = parser.parse_args()
838 if not options.runfile and not options.template:
839 options.cmd = 'runtests'
841 if options.runfile and len(pathnames):
842 fail('Extraneous arguments.')
844 options.pathnames = [os.path.abspath(path) for path in pathnames]
850 options = parse_args()
851 testrun = TestRun(options)
853 if options.cmd is 'runtests':
854 find_tests(testrun, options)
855 elif options.cmd is 'rdconfig':
856 testrun.read(testrun.logger, options)
857 elif options.cmd is 'wrconfig':
858 find_tests(testrun, options)
859 testrun.write(options)
862 fail('Unknown command specified')
864 testrun.complete_outputdirs()
870 if __name__ == '__main__':