1# Copyright 2018 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import json 6import logging 7import os 8 9import dateutil.parser 10 11from autotest_lib.client.common_lib import base_job 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.common_lib.cros import dev_server 14from autotest_lib.server import test 15from autotest_lib.server import utils 16from autotest_lib.server.hosts import cros_host 17from autotest_lib.server.hosts import servo_host 18 19 20# A datetime.DateTime representing the Unix epoch in UTC. 21_UNIX_EPOCH = dateutil.parser.parse('1970-01-01T00:00:00Z') 22 23 24def _encode_utf8_json(j): 25 """Takes JSON object parsed by json.load() family, and encode each unicode 26 strings into utf-8. 27 """ 28 if isinstance(j, unicode): 29 return j.encode('utf-8') 30 if isinstance(j, list): 31 return [_encode_utf8_json(x) for x in j] 32 if isinstance(j, dict): 33 return dict((_encode_utf8_json(k), _encode_utf8_json(v)) 34 for k, v in j.iteritems()) 35 return j 36 37 38class tast(test.test): 39 """Autotest server test that runs a Tast test suite. 40 41 Tast is an integration-testing framework analagous to the test-running 42 portion of Autotest. See 43 https://chromium.googlesource.com/chromiumos/platform/tast/ for more 44 information. 45 46 This class runs the "tast" command locally to execute a Tast test suite on a 47 remote DUT. 48 """ 49 version = 1 50 51 # Maximum time to wait for various tast commands to complete, in seconds. 52 # Note that _LIST_TIMEOUT_SEC includes time to download private test bundles 53 # if run_private_tests=True. 54 _VERSION_TIMEOUT_SEC = 10 55 _LIST_TIMEOUT_SEC = 60 56 57 # Additional time to add to the run timeout (e.g. for collecting crashes and 58 # logs). 59 _RUN_OVERHEAD_SEC = 20 60 # Additional time given to the run command to exit before it's killed. 61 _RUN_EXIT_SEC = 5 62 63 # Number of times to retry SSH connection attempts to the DUT. 64 _SSH_CONNECT_RETRIES = 2 65 66 # File written by the tast command containing test results, as 67 # newline-terminated JSON TestResult objects. 68 _STREAMED_RESULTS_FILENAME = 'streamed_results.jsonl' 69 70 # Text file written by the tast command if a global error caused the test 71 # run to fail (e.g. SSH connection to DUT was lost). 72 _RUN_ERROR_FILENAME = 'run_error.txt' 73 74 # Maximum number of failing and missing tests to include in error messages. 75 _MAX_TEST_NAMES_IN_ERROR = 3 76 77 # Default paths where Tast files are installed by Portage packages. 78 _TAST_PATH = '/usr/bin/tast' 79 _REMOTE_BUNDLE_DIR = '/usr/libexec/tast/bundles/remote' 80 _REMOTE_DATA_DIR = '/usr/share/tast/data' 81 _REMOTE_TEST_RUNNER_PATH = '/usr/bin/remote_test_runner' 82 _DEFAULT_VARS_DIR_PATH = '/etc/tast/vars/private' 83 84 # Alternate locations for Tast files when using Server-Side Packaging. 85 # These files are installed from autotest_server_package.tar.bz2. 86 _SSP_ROOT = '/usr/local/tast' 87 _SSP_TAST_PATH = os.path.join(_SSP_ROOT, 'tast') 88 _SSP_REMOTE_BUNDLE_DIR = os.path.join(_SSP_ROOT, 'bundles/remote') 89 _SSP_REMOTE_DATA_DIR = os.path.join(_SSP_ROOT, 'data') 90 _SSP_REMOTE_TEST_RUNNER_PATH = os.path.join(_SSP_ROOT, 'remote_test_runner') 91 _SSP_DEFAULT_VARS_DIR_PATH = os.path.join(_SSP_ROOT, 'vars') 92 93 # Prefix added to Tast test names when writing their results to TKO 94 # status.log files. 95 _TEST_NAME_PREFIX = 'tast.' 96 97 # Prefixes of keyval keys recorded for missing tests. 98 _MISSING_TEST_KEYVAL_PREFIX = 'tast_missing_test.' 99 100 # Job start/end TKO event status codes from base_client_job._rungroup in 101 # client/bin/job.py. 102 _JOB_STATUS_START = 'START' 103 _JOB_STATUS_END_GOOD = 'END GOOD' 104 _JOB_STATUS_END_FAIL = 'END FAIL' 105 _JOB_STATUS_END_ABORT = 'END ABORT' 106 107 # In-job TKO event status codes from base_client_job._run_test_base in 108 # client/bin/job.py and client/common_lib/error.py. 109 _JOB_STATUS_GOOD = 'GOOD' 110 _JOB_STATUS_FAIL = 'FAIL' 111 112 # Status reason used when an individual Tast test doesn't finish running. 113 _TEST_DID_NOT_FINISH_MSG = 'Test did not finish' 114 115 def initialize(self, host, test_exprs, ignore_test_failures=False, 116 max_run_sec=3600, command_args=[], install_root='/', 117 run_private_tests=True, varsfiles=None): 118 """ 119 @param host: remote.RemoteHost instance representing DUT. 120 @param test_exprs: Array of strings describing tests to run. 121 @param ignore_test_failures: If False, this test will fail if individual 122 Tast tests report failure. If True, this test will only fail in 123 response to the tast command failing to run successfully. This 124 should generally be False when the test is running inline and True 125 when it's running asynchronously. 126 @param max_run_sec: Integer maximum running time for the "tast run" 127 command in seconds. 128 @param command_args: List of arguments passed on the command line via 129 test_that's --args flag, i.e. |args| in control file. 130 @param install_root: Root directory under which Tast binaries are 131 installed. Alternate values may be passed by unit tests. 132 @param run_private_tests: Download and run private tests. 133 @param varsfiles: list of names of yaml files containing variables set 134 in |-varsfile| arguments. 135 136 @raises error.TestFail if the Tast installation couldn't be found. 137 """ 138 self._host = host 139 self._test_exprs = test_exprs 140 self._ignore_test_failures = ignore_test_failures 141 self._max_run_sec = max_run_sec 142 self._command_args = command_args 143 self._install_root = install_root 144 self._run_private_tests = run_private_tests 145 self._fake_now = None 146 self._varsfiles = varsfiles 147 148 # List of JSON objects describing tests that will be run. See Test in 149 # src/platform/tast/src/chromiumos/tast/testing/test.go for details. 150 self._tests_to_run = [] 151 152 # List of JSON objects corresponding to tests from 153 # _STREAMED_RESULTS_FILENAME. See TestResult in 154 # src/platform/tast/src/chromiumos/cmd/tast/run/results.go for details. 155 self._test_results = [] 156 157 # Error message read from _RUN_ERROR_FILENAME, if any. 158 self._run_error = None 159 160 self._tast_path = self._get_path((self._TAST_PATH, self._SSP_TAST_PATH)) 161 self._remote_bundle_dir = self._get_path((self._REMOTE_BUNDLE_DIR, 162 self._SSP_REMOTE_BUNDLE_DIR)) 163 # The data dir can be missing if no remote tests registered data files. 164 self._remote_data_dir = self._get_path((self._REMOTE_DATA_DIR, 165 self._SSP_REMOTE_DATA_DIR), 166 allow_missing=True) 167 self._remote_test_runner_path = self._get_path( 168 (self._REMOTE_TEST_RUNNER_PATH, 169 self._SSP_REMOTE_TEST_RUNNER_PATH)) 170 # Secret vars dir can be missing on public repos. 171 self._default_vars_dir_path = self._get_path( 172 (self._DEFAULT_VARS_DIR_PATH, 173 self._SSP_DEFAULT_VARS_DIR_PATH), 174 allow_missing=True) 175 176 # Register a hook to write the results of individual Tast tests as 177 # top-level entries in the TKO status.log file. 178 self.job.add_post_run_hook(self._log_all_tests) 179 180 def run_once(self): 181 """Runs a single iteration of the test.""" 182 183 self._log_version() 184 self._find_devservers() 185 self._get_tests_to_run() 186 187 run_failed = False 188 try: 189 self._run_tests() 190 except: 191 run_failed = True 192 raise 193 finally: 194 self._read_run_error() 195 # Parse partial results even if the tast command didn't finish. 196 self._parse_results(run_failed) 197 198 def set_fake_now_for_testing(self, now): 199 """Sets a fake timestamp to use in place of time.time() for unit tests. 200 201 @param now Numeric timestamp as would be returned by time.time(). 202 """ 203 self._fake_now = now 204 205 def _get_path(self, paths, allow_missing=False): 206 """Returns the path to an installed Tast-related file or directory. 207 208 @param paths: Tuple or list of absolute paths in root filesystem, e.g. 209 ("/usr/bin/tast", "/usr/local/tast/tast"). 210 @param allow_missing: True if it's okay for the path to be missing. 211 212 @returns Absolute path within install root, e.g. "/usr/bin/tast", or an 213 empty string if the path wasn't found and allow_missing is True. 214 215 @raises error.TestFail if the path couldn't be found and allow_missing 216 is False. 217 """ 218 for path in paths: 219 abs_path = os.path.join(self._install_root, 220 os.path.relpath(path, '/')) 221 if os.path.exists(abs_path): 222 return abs_path 223 224 if allow_missing: 225 return '' 226 raise error.TestFail('None of %s exist' % list(paths)) 227 228 def _get_servo_args(self): 229 """Gets servo-related arguments to pass to "tast run". 230 231 @returns List of command-line flag strings that should be inserted in 232 the command line after "tast run". 233 """ 234 # Start with information provided by the Autotest database. 235 merged_args = {} 236 host_args = servo_host.get_servo_args_for_host(self._host) 237 if host_args: 238 merged_args.update(host_args) 239 240 # Incorporate information that was passed manually. 241 args_dict = utils.args_to_dict(self._command_args) 242 merged_args.update(cros_host.CrosHost.get_servo_arguments(args_dict)) 243 244 logging.info('Autotest servo-related args: %s', merged_args) 245 host_arg = merged_args.get(servo_host.SERVO_HOST_ATTR) 246 port_arg = merged_args.get(servo_host.SERVO_PORT_ATTR) 247 if not host_arg or not port_arg: 248 return [] 249 return ['-var=servo=%s:%s' % (host_arg, port_arg)] 250 251 def _find_devservers(self): 252 """Finds available devservers. 253 254 The result is saved as self._devserver_args. 255 """ 256 devservers, _ = dev_server.ImageServer.get_available_devservers( 257 self._host.hostname) 258 logging.info('Using devservers: %s', ', '.join(devservers)) 259 self._devserver_args = ['-devservers=%s' % ','.join(devservers)] 260 261 def _log_version(self): 262 """Runs the tast command locally to log its version.""" 263 try: 264 utils.run([self._tast_path, '-version'], 265 timeout=self._VERSION_TIMEOUT_SEC, 266 stdout_tee=utils.TEE_TO_LOGS, 267 stderr_tee=utils.TEE_TO_LOGS, 268 stderr_is_expected=True, 269 stdout_level=logging.INFO, 270 stderr_level=logging.ERROR) 271 except error.CmdError as e: 272 logging.error('Failed to log tast version: %s', str(e)) 273 274 def _run_tast(self, subcommand, extra_subcommand_args, timeout_sec, 275 log_stdout=False): 276 """Runs the tast command locally to e.g. list available tests or perform 277 testing against the DUT. 278 279 @param subcommand: Subcommand to pass to the tast executable, e.g. 'run' 280 or 'list'. 281 @param extra_subcommand_args: List of additional subcommand arguments. 282 @param timeout_sec: Integer timeout for the command in seconds. 283 @param log_stdout: If true, write stdout to log. 284 285 @returns client.common_lib.utils.CmdResult object describing the result. 286 287 @raises error.TestFail if the tast command fails or times out. 288 """ 289 cmd = [ 290 self._tast_path, 291 '-verbose=true', 292 '-logtime=false', 293 subcommand, 294 '-build=false', 295 '-remotebundledir=' + self._remote_bundle_dir, 296 '-remotedatadir=' + self._remote_data_dir, 297 '-remoterunner=' + self._remote_test_runner_path, 298 '-sshretries=%d' % self._SSH_CONNECT_RETRIES, 299 ] 300 if subcommand == 'run': 301 cmd.append('-defaultvarsdir=' + self._default_vars_dir_path) 302 cmd.extend(extra_subcommand_args) 303 cmd.append('%s:%d' % (self._host.hostname, self._host.port)) 304 cmd.extend(self._test_exprs) 305 306 logging.info('Running %s', 307 ' '.join([utils.sh_quote_word(a) for a in cmd])) 308 try: 309 return utils.run(cmd, 310 ignore_status=False, 311 timeout=timeout_sec, 312 stdout_tee=(utils.TEE_TO_LOGS if log_stdout 313 else None), 314 stderr_tee=utils.TEE_TO_LOGS, 315 stderr_is_expected=True, 316 stdout_level=logging.INFO, 317 stderr_level=logging.ERROR) 318 except error.CmdError as e: 319 # The tast command's output generally ends with a line describing 320 # the error that was encountered; include it in the first line of 321 # the TestFail exception. Fall back to stderr if stdout is empty (as 322 # is the case with the "list" subcommand, which uses stdout to print 323 # test data). 324 get_last_line = lambda s: s.strip().split('\n')[-1].strip() 325 last_line = (get_last_line(e.result_obj.stdout) or 326 get_last_line(e.result_obj.stderr)) 327 msg = (' (last line: %s)' % last_line) if last_line else '' 328 raise error.TestFail('Failed to run tast%s: %s' % (msg, str(e))) 329 except error.CmdTimeoutError as e: 330 raise error.TestFail('Got timeout while running tast: %s' % str(e)) 331 332 def _get_tests_to_run(self): 333 """Runs the tast command to update the list of tests that will be run. 334 335 @raises error.TestFail if the tast command fails or times out. 336 """ 337 logging.info('Getting list of tests that will be run') 338 args = ['-json=true'] + self._devserver_args 339 if self._run_private_tests: 340 args.append('-downloadprivatebundles=true') 341 result = self._run_tast('list', args, self._LIST_TIMEOUT_SEC) 342 try: 343 self._tests_to_run = _encode_utf8_json( 344 json.loads(result.stdout.strip())) 345 except ValueError as e: 346 raise error.TestFail('Failed to parse tests: %s' % str(e)) 347 if len(self._tests_to_run) == 0: 348 expr = ' '.join([utils.sh_quote_word(a) for a in self._test_exprs]) 349 raise error.TestFail('No tests matched by %s' % expr) 350 351 logging.info('Expect to run %d test(s)', len(self._tests_to_run)) 352 353 def _run_tests(self): 354 """Runs the tast command to perform testing. 355 356 @raises error.TestFail if the tast command fails or times out (but not 357 if individual tests fail). 358 """ 359 args = [ 360 '-resultsdir=' + self.resultsdir, 361 '-waituntilready=true', 362 '-timeout=' + str(self._max_run_sec), 363 '-continueafterfailure=true', 364 ] + self._devserver_args + self._get_servo_args() 365 366 if self._varsfiles: 367 for varsfile in self._varsfiles: 368 args.append('-varsfile=%s' % varsfile) 369 370 if self._run_private_tests: 371 args.append('-downloadprivatebundles=true') 372 373 logging.info('Running tests with timeout of %d sec', self._max_run_sec) 374 self._run_tast('run', args, self._max_run_sec + tast._RUN_EXIT_SEC, 375 log_stdout=True) 376 377 def _read_run_error(self): 378 """Reads a global run error message written by the tast command.""" 379 # The file is only written if a run error occurred. 380 path = os.path.join(self.resultsdir, self._RUN_ERROR_FILENAME) 381 if os.path.exists(path): 382 with open(path, 'r') as f: 383 self._run_error = f.read().strip() 384 385 def _parse_results(self, ignore_missing_file): 386 """Parses results written by the tast command. 387 388 @param ignore_missing_file: If True, return without raising an exception 389 if the Tast results file is missing. This is used to avoid raising a 390 new error if there was already an earlier error while running the 391 tast process. 392 393 @raises error.TestFail if results file is missing and 394 ignore_missing_file is False, or one or more tests failed and 395 _ignore_test_failures is false. 396 """ 397 # The file may not exist if "tast run" failed to run. Tests that were 398 # seen from the earlier "tast list" command will be reported as having 399 # missing results. 400 path = os.path.join(self.resultsdir, self._STREAMED_RESULTS_FILENAME) 401 if not os.path.exists(path): 402 if ignore_missing_file: 403 return 404 raise error.TestFail('Results file %s not found' % path) 405 406 failed = [] 407 seen_test_names = set() 408 with open(path, 'r') as f: 409 for line in f: 410 line = line.strip() 411 if not line: 412 continue 413 try: 414 test = _encode_utf8_json(json.loads(line)) 415 except ValueError as e: 416 raise error.TestFail('Failed to parse %s: %s' % (path, e)) 417 self._test_results.append(test) 418 419 name = test['name'] 420 seen_test_names.add(name) 421 422 if test.get('errors'): 423 for err in test['errors']: 424 logging.warning('%s: %s', name, err['reason']) 425 failed.append(name) 426 else: 427 # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) 428 # end time (preceding the Unix epoch) if it didn't report 429 # completion. 430 if _rfc3339_time_to_timestamp(test['end']) <= 0: 431 failed.append(name) 432 433 missing = [t['name'] for t in self._tests_to_run 434 if t['name'] not in seen_test_names] 435 436 if missing: 437 self._record_missing_tests(missing) 438 439 failure_msg = self._get_failure_message(failed, missing) 440 if failure_msg: 441 raise error.TestFail(failure_msg) 442 443 def _get_failure_message(self, failed, missing): 444 """Returns an error message describing failed and/or missing tests. 445 446 @param failed: List of string names of Tast tests that failed. 447 @param missing: List of string names of Tast tests with missing results. 448 449 @returns String to be used as error.TestFail message. 450 """ 451 def list_tests(names): 452 """Returns a string listing tests. 453 454 @param names: List of string test names. 455 456 @returns String listing tests. 457 """ 458 s = ' '.join(sorted(names)[:self._MAX_TEST_NAMES_IN_ERROR]) 459 if len(names) > self._MAX_TEST_NAMES_IN_ERROR: 460 s += ' ...' 461 return s 462 463 msg = '' 464 if failed and not self._ignore_test_failures: 465 msg = '%d failed: %s' % (len(failed), list_tests(failed)) 466 if missing: 467 if msg: 468 msg += '; ' 469 msg += '%d missing: %s' % (len(missing), list_tests(missing)) 470 return msg 471 472 def _log_all_tests(self): 473 """Writes entries to the TKO status.log file describing the results of 474 all tests. 475 """ 476 seen_test_names = set() 477 for test in self._test_results: 478 self._log_test(test) 479 seen_test_names.add(test['name']) 480 481 def _log_test(self, test): 482 """Writes events to the TKO status.log file describing the results from 483 a Tast test. 484 485 @param test: A JSON object corresponding to a single test from a Tast 486 results.json file. See TestResult in 487 src/platform/tast/src/chromiumos/cmd/tast/run/results.go for 488 details. 489 """ 490 name = test['name'] 491 start_time = _rfc3339_time_to_timestamp(test['start']) 492 end_time = _rfc3339_time_to_timestamp(test['end']) 493 494 test_reported_errors = bool(test.get('errors')) 495 test_skipped = bool(test.get('skipReason')) 496 # The test will have a zero (i.e. 0001-01-01 00:00:00 UTC) end time 497 # (preceding the Unix epoch) if it didn't report completion. 498 test_finished = end_time > 0 499 500 # Avoid reporting tests that were skipped. 501 if test_skipped and not test_reported_errors: 502 return 503 504 self._log_test_event(self._JOB_STATUS_START, name, start_time) 505 506 if test_finished and not test_reported_errors: 507 self._log_test_event(self._JOB_STATUS_GOOD, name, end_time) 508 end_status = self._JOB_STATUS_END_GOOD 509 else: 510 # The previous START event automatically increases the log 511 # indentation level until the following END event. 512 if test_reported_errors: 513 for err in test['errors']: 514 error_time = _rfc3339_time_to_timestamp(err['time']) 515 self._log_test_event(self._JOB_STATUS_FAIL, name, 516 error_time, err['reason']) 517 if not test_finished: 518 # If a run-level error was encountered (e.g. the SSH connection 519 # to the DUT was lost), report it here to make it easier to see 520 # the reason why the test didn't finish. 521 if self._run_error: 522 self._log_test_event(self._JOB_STATUS_FAIL, name, 523 start_time, self._run_error) 524 self._log_test_event(self._JOB_STATUS_FAIL, name, start_time, 525 self._TEST_DID_NOT_FINISH_MSG) 526 end_time = start_time 527 528 end_status = self._JOB_STATUS_END_FAIL 529 530 self._log_test_event(end_status, name, end_time) 531 532 def _log_test_event(self, status_code, test_name, timestamp, message=''): 533 """Logs a single event to the TKO status.log file. 534 535 @param status_code: Event status code, e.g. 'END GOOD'. See 536 client/common_lib/log.py for accepted values. 537 @param test_name: Tast test name, e.g. 'ui.ChromeLogin'. 538 @param timestamp: Event timestamp (as seconds since Unix epoch). 539 @param message: Optional human-readable message. 540 """ 541 full_name = self._TEST_NAME_PREFIX + test_name 542 # The TKO parser code chokes on floating-point timestamps. 543 entry = base_job.status_log_entry(status_code, None, full_name, message, 544 None, timestamp=int(timestamp)) 545 self.job.record_entry(entry, False) 546 547 def _record_missing_tests(self, missing): 548 """Records tests with missing results in job keyval file. 549 550 @param missing: List of string names of Tast tests with missing results. 551 """ 552 keyvals = {} 553 for i, name in enumerate(sorted(missing)): 554 keyvals['%s%d' % (self._MISSING_TEST_KEYVAL_PREFIX, i)] = name 555 utils.write_keyval(self.job.resultdir, keyvals) 556 557 558class _LessBrokenParserInfo(dateutil.parser.parserinfo): 559 """dateutil.parser.parserinfo that interprets years before 100 correctly. 560 561 Our version of dateutil.parser.parse misinteprets an unambiguous string like 562 '0001-01-01T00:00:00Z' as having a two-digit year, which it then converts to 563 2001. This appears to have been fixed by 564 https://github.com/dateutil/dateutil/commit/fc696254. This parserinfo 565 implementation always honors the provided year to prevent this from 566 happening. 567 """ 568 def convertyear(self, year, century_specified=False): 569 """Overrides convertyear in dateutil.parser.parserinfo.""" 570 return int(year) 571 572 573def _rfc3339_time_to_timestamp(time_str): 574 """Converts an RFC3339 time into a Unix timestamp. 575 576 @param time_str: RFC3339-compatible time, e.g. 577 '2018-02-25T07:45:35.916929332-07:00'. 578 579 @returns Float number of seconds since the Unix epoch. Negative if the time 580 precedes the epoch. 581 """ 582 dt = dateutil.parser.parse(time_str, parserinfo=_LessBrokenParserInfo()) 583 return (dt - _UNIX_EPOCH).total_seconds() 584