• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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