1#!@PYTHON@ 2 3# 4# This file and its contents are supplied under the terms of the 5# Common Development and Distribution License ("CDDL"), version 1.0. 6# You may only use this file in accordance with the terms of version 7# 1.0 of the CDDL. 8# 9# A full copy of the text of the CDDL should have accompanied this 10# source. A copy of the CDDL is also available via the Internet at 11# http://www.illumos.org/license/CDDL. 12# 13 14# 15# Copyright (c) 2012, 2016 by Delphix. All rights reserved. 16# Copyright (c) 2017, Chris Fraire <cfraire@me.com>. 17# Copyright 2019 Joyent, Inc. 18# Copyright 2020 OmniOS Community Edition (OmniOSce) Association. 19# Copyright 2026 Gordon W. Ross 20# 21 22from __future__ import print_function 23import sys 24PY3 = sys.version_info[0] == 3 25 26if PY3: 27 import configparser 28else: 29 import ConfigParser as configparser 30 31import io 32import os 33import logging 34import platform 35import re 36from logging.handlers import WatchedFileHandler 37from datetime import datetime 38from optparse import OptionParser 39from pwd import getpwnam 40from pwd import getpwuid 41from select import select 42from subprocess import PIPE 43from subprocess import Popen 44from sys import argv 45from sys import exit 46from sys import maxsize 47from threading import Timer 48from time import time 49 50BASEDIR = '/var/tmp/test_results' 51TESTDIR = '/opt/zfs-tests/' 52KILL = '/usr/bin/kill' 53TRUE = '/usr/bin/true' 54SUDO = '/usr/bin/sudo' 55 56# Exit codes as defined in stf/include/stf.shlib 57STF_PASS = 0 58STF_FAIL = 1 59STF_UNRESOLVED = 2 60STF_NOTINUSE = 3 61STF_UNSUPPORTED = 4 62STF_UNTESTED = 5 63STF_UNINITIATED = 6 64STF_NORESULT = 7 65STF_WARNING = 8 66STF_TIMED_OUT = 9 67STF_ABORTED = 10 68STF_OTHER = 11 69 70retcode = 0 71 72# Custom class to reopen the log file in case it is forcibly closed by a test. 73class WatchedFileHandlerClosed(WatchedFileHandler): 74 """Watch files, including closed files. 75 Similar to (and inherits from) logging.handler.WatchedFileHandler, 76 except that IOErrors are handled by reopening the stream and retrying. 77 This will be retried up to a configurable number of times before 78 giving up, default 5. 79 """ 80 81 def __init__(self, filename, mode='a', encoding='utf-8', delay=0, max_tries=5): 82 self.max_tries = max_tries 83 self.tries = 0 84 WatchedFileHandler.__init__(self, filename, mode, encoding, delay) 85 86 def emit(self, record): 87 while True: 88 try: 89 WatchedFileHandler.emit(self, record) 90 self.tries = 0 91 return 92 except IOError as err: 93 if self.tries == self.max_tries: 94 raise 95 self.stream.close() 96 self.stream = self._open() 97 self.tries += 1 98 99 100class RunAborted(Exception): 101 pass 102 103 104class Result(object): 105 total = 0 106 runresults = {'PASS': 0, 'FAIL': 0, 'SKIP': 0, 'KILLED': 0} 107 108 def __init__(self): 109 self.starttime = None 110 self.returncode = None 111 self.runtime = '' 112 self.stdout = [] 113 self.stderr = [] 114 self.result = '' 115 116 def done(self, proc, killed): 117 """ 118 Finalize the results of this Cmd, mapping exit codes to a 119 result code, one of: (PASS, FAIL, SKIP, KILLED, ABORTED) 120 and increment one of the four runresults[] counters. 121 """ 122 global retcode 123 124 Result.total += 1 125 m, s = divmod(time() - self.starttime, 60) 126 self.runtime = '%02d:%02d' % (m, s) 127 self.returncode = proc.returncode 128 if killed: 129 self.result = 'KILLED' 130 Result.runresults['KILLED'] += 1 131 retcode = 2; 132 elif self.returncode == 0: 133 self.result = 'PASS' 134 Result.runresults['PASS'] += 1 135 elif self.returncode in (STF_NOTINUSE, STF_UNSUPPORTED): 136 self.result = 'SKIP' 137 Result.runresults['SKIP'] += 1 138 elif self.returncode == STF_ABORTED: 139 self.result = 'ABORTED' 140 Result.runresults['FAIL'] += 1 141 retcode = 1 142 elif self.returncode != 0: 143 self.result = 'FAIL' 144 Result.runresults['FAIL'] += 1 145 retcode = 1; 146 147 148class Output(object): 149 """ 150 This class is a slightly modified version of the 'Stream' class found 151 here: http://goo.gl/aSGfv 152 """ 153 def __init__(self, stream): 154 self.stream = stream 155 self._buf = '' 156 self.lines = [] 157 158 def fileno(self): 159 return self.stream.fileno() 160 161 def read(self, drain=0): 162 """ 163 Read from the file descriptor. If 'drain' set, read until EOF. 164 """ 165 while self._read() is not None: 166 if not drain: 167 break 168 169 def _read(self): 170 """ 171 Read up to 4k of data from this output stream. Collect the output 172 up to the last newline, and append it to any leftover data from a 173 previous call. The lines are stored as a (timestamp, data) tuple 174 for easy sorting/merging later. 175 """ 176 fd = self.fileno() 177 buf = os.read(fd, 4096).decode('utf-8', errors='ignore') 178 if not buf: 179 return None 180 if '\n' not in buf: 181 self._buf += buf 182 return [] 183 184 buf = self._buf + buf 185 tmp, rest = buf.rsplit('\n', 1) 186 self._buf = rest 187 now = datetime.now() 188 rows = tmp.split('\n') 189 self.lines += [(now, r) for r in rows] 190 191 192class Cmd(object): 193 verified_users = [] 194 195 def __init__(self, pathname, identifier=None, outputdir=None, 196 timeout=None, user=None, tags=None): 197 self.pathname = pathname 198 self.identifier = identifier 199 self.outputdir = outputdir or 'BASEDIR' 200 self.timeout = timeout 201 self.user = user or '' 202 self.killed = False 203 self.result = Result() 204 205 if self.timeout is None: 206 self.timeout = 60 207 208 def __str__(self): 209 return '''\ 210Pathname: %s 211Identifier: %s 212Outputdir: %s 213Timeout: %d 214User: %s 215''' % (self.pathname, self.identifier, self.outputdir, self.timeout, self.user) 216 217 def kill_cmd(self, proc): 218 """ 219 Kill a running command due to timeout, or ^C from the keyboard. If 220 sudo is required, this user was verified previously. We kill the 221 entire process group so that SIGTERM reaches the test script and 222 its children (e.g. when launched via sudo), allowing EXIT traps 223 and cleanup functions to run. 224 """ 225 self.killed = True 226 do_sudo = len(self.user) != 0 227 signal = '-TERM' 228 229 # Negative PID sends signal to the entire process group. 230 cmd = [SUDO, KILL, signal, '-%s' % str(proc.pid)] 231 if not do_sudo: 232 del cmd[0] 233 234 try: 235 kp = Popen(cmd) 236 kp.wait() 237 except: 238 pass 239 240 def update_cmd_privs(self, cmd, user): 241 """ 242 If a user has been specified to run this Cmd and we're not already 243 running as that user, prepend the appropriate sudo command to run 244 as that user. 245 """ 246 me = getpwuid(os.getuid()) 247 248 if not user or user == me.pw_name: 249 return cmd 250 251 ret = '%s -E -u %s %s' % (SUDO, user, cmd) 252 return ret.split(' ') 253 254 def collect_output(self, proc): 255 """ 256 Read from stdout/stderr as data becomes available, until the 257 process is no longer running. Return the lines from the stdout and 258 stderr Output objects. 259 """ 260 out = Output(proc.stdout) 261 err = Output(proc.stderr) 262 res = [] 263 while proc.returncode is None: 264 proc.poll() 265 res = select([out, err], [], [], .1) 266 for fd in res[0]: 267 fd.read() 268 for fd in res[0]: 269 fd.read(drain=1) 270 271 return out.lines, err.lines 272 273 def run(self, options): 274 """ 275 This is the main function that runs each individual test. 276 Determine whether or not the command requires sudo, and modify it 277 if needed. Run the command, and update the result object. 278 """ 279 if options.dryrun is True: 280 print(self) 281 return 282 283 privcmd = self.update_cmd_privs(self.pathname, self.user) 284 try: 285 old = os.umask(0) 286 if not os.path.isdir(self.outputdir): 287 os.makedirs(self.outputdir, mode=0o777) 288 os.umask(old) 289 except OSError as e: 290 fail('%s' % e) 291 292 try: 293 self.result.starttime = time() 294 proc = Popen(privcmd, stdout=PIPE, stderr=PIPE, stdin=PIPE, 295 universal_newlines=True, start_new_session=True) 296 proc.stdin.close() 297 298 # Allow a special timeout value of 0 to mean infinity 299 if int(self.timeout) == 0: 300 self.timeout = maxsize 301 t = Timer(int(self.timeout), self.kill_cmd, [proc]) 302 t.start() 303 self.result.stdout, self.result.stderr = self.collect_output(proc) 304 except KeyboardInterrupt: 305 self.kill_cmd(proc) 306 raise 307 finally: 308 t.cancel() 309 310 self.result.done(proc, self.killed) 311 312 def skip(self): 313 """ 314 Initialize enough of the test result that we can log a skipped 315 command. 316 """ 317 Result.total += 1 318 Result.runresults['SKIP'] += 1 319 self.result.stdout = self.result.stderr = [] 320 self.result.starttime = time() 321 m, s = divmod(time() - self.result.starttime, 60) 322 self.result.runtime = '%02d:%02d' % (m, s) 323 self.result.result = 'SKIP' 324 325 def log(self, logger, options): 326 """ 327 This function is responsible for writing all output. This includes 328 the console output, the logfile of all results (with timestamped 329 merged stdout and stderr), and for each test, the unmodified 330 stdout/stderr/merged in it's own file. 331 """ 332 if logger is None: 333 return 334 335 logname = getpwuid(os.getuid()).pw_name 336 user = ' (run as %s)' % (self.user if len(self.user) else logname) 337 if self.identifier: 338 msga = 'Test (%s): %s%s ' % (self.identifier, self.pathname, user) 339 else: 340 msga = 'Test: %s%s ' % (self.pathname, user) 341 msgb = '[%s] [%s]' % (self.result.runtime, self.result.result) 342 pad = ' ' * (80 - (len(msga) + len(msgb))) 343 344 # If -q is specified, only print a line for tests that didn't pass. 345 # This means passing tests need to be logged as DEBUG, or the one 346 # line summary will only be printed in the logfile for failures. 347 if not options.quiet: 348 logger.info('%s%s%s' % (msga, pad, msgb)) 349 elif self.result.result != 'PASS': 350 logger.info('%s%s%s' % (msga, pad, msgb)) 351 else: 352 logger.debug('%s%s%s' % (msga, pad, msgb)) 353 354 lines = sorted(self.result.stdout + self.result.stderr, 355 key=lambda x: x[0]) 356 357 for dt, line in lines: 358 logger.debug('%s %s' % (dt.strftime("%H:%M:%S.%f ")[:11], line)) 359 360 if len(self.result.stdout): 361 with io.open(os.path.join(self.outputdir, 'stdout'), 362 encoding='utf-8', 363 errors='surrogateescape', 364 mode='w') as out: 365 for _, line in self.result.stdout: 366 out.write('%s\n' % line) 367 if len(self.result.stderr): 368 with io.open(os.path.join(self.outputdir, 'stderr'), 369 encoding='utf-8', 370 errors='surrogateescape', 371 mode='w') as err: 372 for _, line in self.result.stderr: 373 err.write('%s\n' % line) 374 if len(self.result.stdout) and len(self.result.stderr): 375 with io.open(os.path.join(self.outputdir, 'merged'), 376 encoding='utf-8', 377 errors='surrogateescape', 378 mode='w') as merged: 379 for _, line in lines: 380 merged.write('%s\n' % line) 381 382 383class Test(Cmd): 384 props = ['outputdir', 'timeout', 'user', 'pre', 'pre_user', 'post', 385 'post_user', 'tags'] 386 387 def __init__(self, pathname, 388 pre=None, pre_user=None, post=None, post_user=None, 389 tags=None, **kwargs): 390 super(Test, self).__init__(pathname, **kwargs) 391 self.pre = pre or '' 392 self.pre_user = pre_user or '' 393 self.post = post or '' 394 self.post_user = post_user or '' 395 self.tags = tags or [] 396 397 def __str__(self): 398 post_user = pre_user = '' 399 if len(self.pre_user): 400 pre_user = ' (as %s)' % (self.pre_user) 401 if len(self.post_user): 402 post_user = ' (as %s)' % (self.post_user) 403 return '''\ 404Pathname: %s 405Identifier: %s 406Outputdir: %s 407Timeout: %d 408User: %s 409Pre: %s%s 410Post: %s%s 411Tags: %s 412''' % (self.pathname, self.identifier, self.outputdir, self.timeout, self.user, 413 self.pre, pre_user, self.post, post_user, self.tags) 414 415 def verify(self, logger): 416 """ 417 Check the pre/post scripts, user and Test. Omit the Test from this 418 run if there are any problems. 419 """ 420 files = [self.pre, self.pathname, self.post] 421 users = [self.pre_user, self.user, self.post_user] 422 423 for f in [f for f in files if len(f)]: 424 if not verify_file(f): 425 logger.info("Warning: Test '%s' not added to this run because" 426 " it failed verification." % f) 427 return False 428 429 for user in [user for user in users if len(user)]: 430 if not verify_user(user, logger): 431 logger.info("Not adding Test '%s' to this run." % 432 self.pathname) 433 return False 434 435 return True 436 437 def run(self, logger, options): 438 """ 439 Create Cmd instances for the pre/post scripts. If the pre script 440 doesn't pass, skip this Test. Run the post script regardless. 441 """ 442 odir = os.path.join(self.outputdir, os.path.basename(self.pre)) 443 pretest = Cmd(self.pre, identifier=self.identifier, outputdir=odir, 444 timeout=self.timeout, user=self.pre_user) 445 test = Cmd(self.pathname, identifier=self.identifier, 446 outputdir=self.outputdir, timeout=self.timeout, 447 user=self.user) 448 odir = os.path.join(self.outputdir, os.path.basename(self.post)) 449 posttest = Cmd(self.post, identifier=self.identifier, outputdir=odir, 450 timeout=self.timeout, user=self.post_user) 451 452 cont = True 453 if len(pretest.pathname): 454 pretest.run(options) 455 cont = pretest.result.result == 'PASS' 456 pretest.log(logger, options) 457 458 if cont: 459 test.run(options) 460 else: 461 test.skip() 462 463 test.log(logger, options) 464 if test.result.result == 'ABORTED': 465 raise RunAborted('test %s' % test.pathname) 466 467 if len(posttest.pathname): 468 logger.debug('Running post cleanup: %s' % posttest.pathname) 469 posttest.run(options) 470 posttest.log(logger, options) 471 if posttest.result.result == 'ABORTED': 472 raise RunAborted( 473 'post cleanup for %s' % self.pathname) 474 475 476class TestGroup(Test): 477 props = Test.props + ['tests'] 478 479 def __init__(self, pathname, tests=None, **kwargs): 480 super(TestGroup, self).__init__(pathname, **kwargs) 481 self.tests = tests or [] 482 483 def __str__(self): 484 post_user = pre_user = '' 485 if len(self.pre_user): 486 pre_user = ' (as %s)' % (self.pre_user) 487 if len(self.post_user): 488 post_user = ' (as %s)' % (self.post_user) 489 return '''\ 490Pathname: %s 491Identifier: %s 492Outputdir: %s 493Tests: %s 494Timeout: %d 495User: %s 496Pre: %s%s 497Post: %s%s 498Tags: %s 499''' % (self.pathname, self.identifier, self.outputdir, self.tests, 500 self.timeout, self.user, self.pre, pre_user, self.post, post_user, 501 self.tags) 502 503 def filter(self, keeplist): 504 self.tests = [ x for x in self.tests if x in keeplist ] 505 506 def verify(self, logger): 507 """ 508 Check the pre/post scripts, user and tests in this TestGroup. Omit 509 the TestGroup entirely, or simply delete the relevant tests in the 510 group, if that's all that's required. 511 """ 512 # If the pre or post scripts are relative pathnames, convert to 513 # absolute, so they stand a chance of passing verification. 514 if len(self.pre) and not os.path.isabs(self.pre): 515 self.pre = os.path.join(self.pathname, self.pre) 516 if len(self.post) and not os.path.isabs(self.post): 517 self.post = os.path.join(self.pathname, self.post) 518 519 auxfiles = [self.pre, self.post] 520 users = [self.pre_user, self.user, self.post_user] 521 522 for f in [f for f in auxfiles if len(f)]: 523 if self.pathname != os.path.dirname(f): 524 logger.info("Warning: TestGroup '%s' not added to this run. " 525 "Auxiliary script '%s' exists in a different " 526 "directory." % (self.pathname, f)) 527 return False 528 529 if not verify_file(f): 530 logger.info("Warning: TestGroup '%s' not added to this run. " 531 "Auxiliary script '%s' failed verification." % 532 (self.pathname, f)) 533 return False 534 535 for user in [user for user in users if len(user)]: 536 if not verify_user(user, logger): 537 logger.info("Not adding TestGroup '%s' to this run." % 538 self.pathname) 539 return False 540 541 # If one of the tests is invalid, delete it, log it, and drive on. 542 self.tests[:] = [f for f in self.tests if 543 verify_file(os.path.join(self.pathname, f))] 544 545 return len(self.tests) != 0 546 547 def run(self, logger, options): 548 """ 549 Create Cmd instances for the pre/post scripts. If the pre script 550 doesn't pass, skip all the tests in this TestGroup. Run the post 551 script regardless. 552 """ 553 # tags assigned to this test group also include the test names 554 if options.tags and not set(self.tags).intersection(set(options.tags)): 555 return 556 557 odir = os.path.join(self.outputdir, os.path.basename(self.pre)) 558 pretest = Cmd(self.pre, outputdir=odir, timeout=self.timeout, 559 user=self.pre_user, identifier=self.identifier) 560 odir = os.path.join(self.outputdir, os.path.basename(self.post)) 561 posttest = Cmd(self.post, outputdir=odir, timeout=self.timeout, 562 user=self.post_user, identifier=self.identifier) 563 564 cont = True 565 if len(pretest.pathname): 566 pretest.run(options) 567 cont = pretest.result.result == 'PASS' 568 pretest.log(logger, options) 569 570 try: 571 for fname in self.tests: 572 test = Cmd(os.path.join(self.pathname, fname), 573 outputdir=os.path.join(self.outputdir, fname), 574 timeout=self.timeout, user=self.user, 575 identifier=self.identifier) 576 if cont: 577 test.run(options) 578 else: 579 test.skip() 580 581 test.log(logger, options) 582 if test.result.result == 'ABORTED': 583 raise RunAborted('test %s' % test.pathname) 584 except KeyboardInterrupt: 585 # Run the post (cleanup) script even when interrupted, in case 586 # the test's own EXIT trap did not clean up (e.g. pools, zinject). 587 if len(posttest.pathname): 588 print('\nInterrupted, running cleanup...', 589 flush=True) 590 posttest.run(options) 591 posttest.log(logger, options) 592 raise 593 594 if len(posttest.pathname): 595 logger.debug('Running post cleanup: %s' % posttest.pathname) 596 posttest.run(options) 597 posttest.log(logger, options) 598 if posttest.result.result == 'ABORTED': 599 raise RunAborted( 600 'post cleanup for %s' % self.pathname) 601 602 603class TestRun(object): 604 props = ['quiet', 'outputdir'] 605 606 def __init__(self, options): 607 self.tests = {} 608 self.testgroups = {} 609 self.starttime = time() 610 self.timestamp = datetime.now().strftime('%Y%m%dT%H%M%S') 611 self.outputdir = os.path.join(options.outputdir, self.timestamp) 612 self.logger = self.setup_logging(options) 613 self.defaults = [ 614 ('outputdir', BASEDIR), 615 ('quiet', False), 616 ('timeout', 60), 617 ('user', ''), 618 ('pre', ''), 619 ('pre_user', ''), 620 ('post', ''), 621 ('post_user', ''), 622 ('tags', []) 623 ] 624 625 def __str__(self): 626 s = 'TestRun:\n outputdir: %s\n' % self.outputdir 627 s += 'TESTS:\n' 628 for key in sorted(self.tests.keys()): 629 s += '%s%s' % (self.tests[key].__str__(), '\n') 630 s += 'TESTGROUPS:\n' 631 for key in sorted(self.testgroups.keys()): 632 s += '%s%s' % (self.testgroups[key].__str__(), '\n') 633 return s 634 635 def addtest(self, pathname, options): 636 """ 637 Create a new Test, and apply any properties that were passed in 638 from the command line. If it passes verification, add it to the 639 TestRun. 640 """ 641 test = Test(pathname) 642 for prop in Test.props: 643 setattr(test, prop, getattr(options, prop)) 644 645 if test.verify(self.logger): 646 self.tests[pathname] = test 647 648 def addtestgroup(self, dirname, filenames, options): 649 """ 650 Create a new TestGroup, and apply any properties that were passed 651 in from the command line. If it passes verification, add it to the 652 TestRun. 653 """ 654 if dirname not in self.testgroups: 655 testgroup = TestGroup(dirname) 656 for prop in Test.props: 657 setattr(testgroup, prop, getattr(options, prop)) 658 659 # Prevent pre/post scripts from running as regular tests 660 for f in [testgroup.pre, testgroup.post]: 661 if f in filenames: 662 del filenames[filenames.index(f)] 663 664 self.testgroups[dirname] = testgroup 665 self.testgroups[dirname].tests = sorted(filenames) 666 667 testgroup.verify(self.logger) 668 669 def filter(self, keeplist): 670 for group in list(self.testgroups.keys()): 671 if group not in keeplist: 672 del self.testgroups[group] 673 continue 674 675 g = self.testgroups[group] 676 677 if g.pre and os.path.basename(g.pre) in keeplist[group]: 678 continue 679 680 g.filter(keeplist[group]) 681 682 for test in list(self.tests.keys()): 683 directory, base = os.path.split(test) 684 if directory not in keeplist or base not in keeplist[directory]: 685 del self.tests[test] 686 687 def read(self, logger, options): 688 """ 689 Read in the specified runfile, and apply the TestRun properties 690 listed in the 'DEFAULT' section to our TestRun. Then read each 691 section, and apply the appropriate properties to the Test or 692 TestGroup. Properties from individual sections override those set 693 in the 'DEFAULT' section. If the Test or TestGroup passes 694 verification, add it to the TestRun. 695 """ 696 config = configparser.RawConfigParser() 697 parsed = config.read(options.runfiles) 698 failed = options.runfiles - set(parsed) 699 if len(failed): 700 files = ' '.join(sorted(failed)) 701 fail("Couldn't read config files: %s" % files) 702 703 for opt in TestRun.props: 704 if config.has_option('DEFAULT', opt): 705 setattr(self, opt, config.get('DEFAULT', opt)) 706 self.outputdir = os.path.join(self.outputdir, self.timestamp) 707 708 testdir = options.testdir 709 710 for section in config.sections(): 711 if ('arch' in config.options(section) and 712 platform.machine() != config.get(section, 'arch')): 713 continue 714 715 parts = section.split(':', 1) 716 sectiondir = parts[0] 717 identifier = parts[1] if len(parts) == 2 else None 718 if os.path.isdir(sectiondir): 719 pathname = sectiondir 720 elif os.path.isdir(os.path.join(testdir, sectiondir)): 721 pathname = os.path.join(testdir, sectiondir) 722 else: 723 pathname = sectiondir 724 725 testgroup = TestGroup(os.path.abspath(pathname), 726 identifier=identifier) 727 if 'tests' in config.options(section): 728 for prop in TestGroup.props: 729 for sect in ['DEFAULT', section]: 730 if config.has_option(sect, prop): 731 if prop == 'tags': 732 setattr(testgroup, prop, 733 eval(config.get(sect, prop))) 734 else: 735 setattr(testgroup, prop, 736 config.get(sect, prop)) 737 738 # Repopulate tests using eval to convert the string to a list 739 testgroup.tests = eval(config.get(section, 'tests')) 740 741 if testgroup.verify(logger): 742 self.testgroups[section] = testgroup 743 744 elif 'autotests' in config.options(section): 745 for prop in TestGroup.props: 746 for sect in ['DEFAULT', section]: 747 if config.has_option(sect, prop): 748 setattr(testgroup, prop, config.get(sect, prop)) 749 750 filenames = os.listdir(pathname) 751 # only files starting with "tst." are considered tests 752 filenames = [f for f in filenames if f.startswith("tst.")] 753 testgroup.tests = sorted(filenames) 754 755 if testgroup.verify(logger): 756 self.testgroups[section] = testgroup 757 else: 758 test = Test(section) 759 for prop in Test.props: 760 for sect in ['DEFAULT', section]: 761 if config.has_option(sect, prop): 762 setattr(test, prop, config.get(sect, prop)) 763 764 if test.verify(logger): 765 self.tests[section] = test 766 767 def write(self, options): 768 """ 769 Create a configuration file for editing and later use. The 770 'DEFAULT' section of the config file is created from the 771 properties that were specified on the command line. Tests are 772 simply added as sections that inherit everything from the 773 'DEFAULT' section. TestGroups are the same, except they get an 774 option including all the tests to run in that directory. 775 """ 776 777 defaults = dict([(prop, getattr(options, prop)) for prop, _ in 778 self.defaults]) 779 config = configparser.RawConfigParser(defaults) 780 781 for test in sorted(self.tests.keys()): 782 config.add_section(test) 783 for prop in Test.props: 784 if prop not in self.props: 785 config.set(testgroup, prop, 786 getattr(self.testgroups[testgroup], prop)) 787 788 for testgroup in sorted(self.testgroups.keys()): 789 config.add_section(testgroup) 790 config.set(testgroup, 'tests', self.testgroups[testgroup].tests) 791 for prop in TestGroup.props: 792 if prop not in self.props: 793 config.set(testgroup, prop, 794 getattr(self.testgroups[testgroup], prop)) 795 796 try: 797 with open(options.template, 'w') as f: 798 return config.write(f) 799 except IOError: 800 fail('Could not open \'%s\' for writing.' % options.template) 801 802 def complete_outputdirs(self): 803 """ 804 Collect all the pathnames for Tests, and TestGroups. Work 805 backwards one pathname component at a time, to create a unique 806 directory name in which to deposit test output. Tests will be able 807 to write output files directly in the newly modified outputdir. 808 TestGroups will be able to create one subdirectory per test in the 809 outputdir, and are guaranteed uniqueness because a group can only 810 contain files in one directory. Pre and post tests will create a 811 directory rooted at the outputdir of the Test or TestGroup in 812 question for their output. 813 """ 814 done = False 815 components = 0 816 tmp_dict = dict(list(self.tests.items()) + list(self.testgroups.items())) 817 total = len(tmp_dict) 818 base = self.outputdir 819 820 while not done: 821 l = [] 822 components -= 1 823 for testfile in list(tmp_dict.keys()): 824 uniq = '/'.join(testfile.split('/')[components:]).lstrip('/') 825 if uniq not in l: 826 l.append(uniq) 827 tmp_dict[testfile].outputdir = os.path.join(base, uniq) 828 else: 829 break 830 done = total == len(l) 831 832 def setup_logging(self, options): 833 """ 834 Two loggers are set up here. The first is for the logfile which 835 will contain one line summarizing the test, including the test 836 name, result, and running time. This logger will also capture the 837 timestamped combined stdout and stderr of each run. The second 838 logger is optional console output, which will contain only the one 839 line summary. The loggers are initialized at two different levels 840 to facilitate segregating the output. 841 """ 842 if options.dryrun is True: 843 return 844 845 testlogger = logging.getLogger(__name__) 846 testlogger.setLevel(logging.DEBUG) 847 848 if not options.template: 849 try: 850 old = os.umask(0) 851 os.makedirs(self.outputdir, mode=0o777) 852 os.umask(old) 853 except OSError as e: 854 fail('%s' % e) 855 filename = os.path.join(self.outputdir, 'log') 856 857 logfile = WatchedFileHandlerClosed(filename) 858 logfile.setLevel(logging.DEBUG) 859 logfilefmt = logging.Formatter('%(message)s') 860 logfile.setFormatter(logfilefmt) 861 testlogger.addHandler(logfile) 862 863 cons = logging.StreamHandler() 864 cons.setLevel(logging.INFO) 865 consfmt = logging.Formatter('%(message)s') 866 cons.setFormatter(consfmt) 867 testlogger.addHandler(cons) 868 869 return testlogger 870 871 def run(self, options): 872 """ 873 Walk through all the Tests and TestGroups, calling run(). 874 """ 875 if not options.dryrun: 876 try: 877 os.chdir(self.outputdir) 878 except OSError: 879 fail('Could not change to directory %s' % self.outputdir) 880 881 uname = Popen(['uname', '-a'], stdout=PIPE, stderr=PIPE, 882 universal_newlines=True) 883 out, _ = uname.communicate() 884 self.logger.debug('Test system: %s' % out.strip()) 885 886 for test in sorted(self.tests.keys()): 887 self.tests[test].run(self.logger, options) 888 try: 889 for testgroup in sorted(self.testgroups.keys()): 890 self.testgroups[testgroup].run(self.logger, options) 891 except KeyboardInterrupt: 892 fail('\nRun terminated at user request.') 893 except RunAborted as e: 894 fail('\nRun aborted: %s' % e) 895 896 def summary(self): 897 if Result.total == 0: 898 print('No tests to run') 899 return 900 901 print('\nResults Summary') 902 for key in list(Result.runresults.keys()): 903 if Result.runresults[key] != 0: 904 print('%s\t% 4d' % (key, Result.runresults[key])) 905 906 m, s = divmod(time() - self.starttime, 60) 907 h, m = divmod(m, 60) 908 print('\nRunning Time:\t%02d:%02d:%02d' % (h, m, s)) 909 print('Percent passed:\t%.1f%%' % ((float(Result.runresults['PASS']) / 910 float(Result.total)) * 100)) 911 print('Log directory:\t%s' % self.outputdir) 912 913 914def verify_file(pathname): 915 """ 916 Verify that the supplied pathname is an executable regular file. 917 """ 918 if os.path.isdir(pathname) or os.path.islink(pathname): 919 return False 920 921 if os.path.isfile(pathname) and os.access(pathname, os.X_OK): 922 return True 923 924 return False 925 926 927def verify_user(user, logger): 928 """ 929 Verify that the specified user exists on this system, and can execute 930 sudo without being prompted for a password. 931 """ 932 testcmd = [SUDO, '-n', '-u', user, TRUE] 933 934 if user in Cmd.verified_users: 935 return True 936 937 try: 938 _ = getpwnam(user) 939 except KeyError: 940 logger.info("Warning: user '%s' does not exist.", user) 941 return False 942 943 p = Popen(testcmd) 944 p.wait() 945 if p.returncode != 0: 946 logger.info("Warning: user '%s' cannot use passwordless sudo.", user) 947 return False 948 else: 949 Cmd.verified_users.append(user) 950 951 return True 952 953 954def find_tests(testrun, options): 955 """ 956 For the given list of pathnames, add files as Tests. For directories, 957 if do_groups is True, add the directory as a TestGroup. If False, 958 recursively search for executable files. 959 """ 960 961 for p in sorted(options.pathnames): 962 if os.path.isdir(p): 963 for dirname, _, filenames in os.walk(p): 964 if options.do_groups: 965 testrun.addtestgroup(dirname, filenames, options) 966 else: 967 for f in sorted(filenames): 968 testrun.addtest(os.path.join(dirname, f), options) 969 else: 970 testrun.addtest(p, options) 971 972 973def filter_tests(testrun, options): 974 try: 975 fh = open(options.logfile, "r", errors='replace') 976 except Exception as e: 977 fail('%s' % e) 978 979 failed = {} 980 while True: 981 line = fh.readline() 982 if not line: 983 break 984 m = re.match(r'Test: (.*)/(\S+).*\[FAIL\]', line) 985 if not m: 986 continue 987 group, test = m.group(1, 2) 988 m = re.match(re.escape(options.testdir) + r'(.*)', group) 989 if m: 990 group = m.group(1) 991 try: 992 failed[group].append(test) 993 except KeyError: 994 failed[group] = [ test ] 995 fh.close() 996 997 testrun.filter(failed) 998 999 1000def fail(retstr, ret=1): 1001 print('%s: %s' % (argv[0], retstr)) 1002 exit(ret) 1003 1004 1005def options_cb(option, opt_str, value, parser): 1006 path_options = ['outputdir', 'template', 'testdir', 'logfile'] 1007 1008 if opt_str in parser.rargs: 1009 fail('%s may only be specified once.' % opt_str) 1010 1011 if option.dest == 'runfiles': 1012 parser.values.cmd = 'rdconfig' 1013 value = set(os.path.abspath(p) for p in value.split(',')) 1014 if option.dest == 'tags': 1015 value = [x.strip() for x in value.split(',')] 1016 1017 if option.dest in path_options: 1018 setattr(parser.values, option.dest, os.path.abspath(value)) 1019 else: 1020 setattr(parser.values, option.dest, value) 1021 1022 1023def parse_args(): 1024 parser = OptionParser() 1025 parser.add_option('-c', action='callback', callback=options_cb, 1026 type='string', dest='runfiles', metavar='runfiles', 1027 help='Specify tests to run via config files.') 1028 parser.add_option('-d', action='store_true', default=False, dest='dryrun', 1029 help='Dry run. Print tests, but take no other action.') 1030 parser.add_option('-l', action='callback', callback=options_cb, 1031 default=None, dest='logfile', metavar='logfile', 1032 type='string', 1033 help='Read logfile and re-run tests which failed.') 1034 parser.add_option('-g', action='store_true', default=False, 1035 dest='do_groups', help='Make directories TestGroups.') 1036 parser.add_option('-o', action='callback', callback=options_cb, 1037 default=BASEDIR, dest='outputdir', type='string', 1038 metavar='outputdir', help='Specify an output directory.') 1039 parser.add_option('-i', action='callback', callback=options_cb, 1040 default=TESTDIR, dest='testdir', type='string', 1041 metavar='testdir', help='Specify a test directory.') 1042 parser.add_option('-p', action='callback', callback=options_cb, 1043 default='', dest='pre', metavar='script', 1044 type='string', help='Specify a pre script.') 1045 parser.add_option('-P', action='callback', callback=options_cb, 1046 default='', dest='post', metavar='script', 1047 type='string', help='Specify a post script.') 1048 parser.add_option('-q', action='store_true', default=False, dest='quiet', 1049 help='Silence on the console during a test run.') 1050 parser.add_option('-t', action='callback', callback=options_cb, default=60, 1051 dest='timeout', metavar='seconds', type='int', 1052 help='Timeout (in seconds) for an individual test.') 1053 parser.add_option('-u', action='callback', callback=options_cb, 1054 default='', dest='user', metavar='user', type='string', 1055 help='Specify a different user name to run as.') 1056 parser.add_option('-w', action='callback', callback=options_cb, 1057 default=None, dest='template', metavar='template', 1058 type='string', help='Create a new config file.') 1059 parser.add_option('-x', action='callback', callback=options_cb, default='', 1060 dest='pre_user', metavar='pre_user', type='string', 1061 help='Specify a user to execute the pre script.') 1062 parser.add_option('-X', action='callback', callback=options_cb, default='', 1063 dest='post_user', metavar='post_user', type='string', 1064 help='Specify a user to execute the post script.') 1065 parser.add_option('-T', action='callback', callback=options_cb, default='', 1066 dest='tags', metavar='tags', type='string', 1067 help='Specify tags to execute specific test groups.') 1068 (options, pathnames) = parser.parse_args() 1069 1070 if options.runfiles and len(pathnames): 1071 fail('Extraneous arguments.') 1072 1073 options.pathnames = [os.path.abspath(path) for path in pathnames] 1074 1075 return options 1076 1077 1078def main(): 1079 options = parse_args() 1080 1081 testrun = TestRun(options) 1082 1083 if options.runfiles: 1084 testrun.read(testrun.logger, options) 1085 else: 1086 find_tests(testrun, options) 1087 1088 if options.logfile: 1089 filter_tests(testrun, options) 1090 1091 if options.template: 1092 testrun.write(options) 1093 exit(0) 1094 1095 testrun.complete_outputdirs() 1096 testrun.run(options) 1097 testrun.summary() 1098 exit(retcode) 1099 1100 1101if __name__ == '__main__': 1102 main() 1103