xref: /aosp_15_r20/external/autotest/server/cros/dynamic_suite/suite_common.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright 2018 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Shared functions by dynamic_suite/suite.py & skylab_suite/cros_suite.py."""
7
8from __future__ import absolute_import
9from __future__ import division
10from __future__ import print_function
11
12import datetime
13import logging
14import multiprocessing
15import re
16import six
17from six.moves import zip
18
19import common
20
21from autotest_lib.client.common_lib import control_data
22from autotest_lib.client.common_lib import error
23from autotest_lib.client.common_lib import time_utils
24from autotest_lib.client.common_lib.cros import dev_server
25from autotest_lib.server.cros import provision
26from autotest_lib.server.cros.dynamic_suite import constants
27from autotest_lib.server.cros.dynamic_suite import control_file_getter
28from autotest_lib.server.cros.dynamic_suite import tools
29
30# TODO(ayatane): Obsolete, not cleaning up now due to time.
31ENABLE_CONTROLS_IN_BATCH = False
32
33
34def canonicalize_suite_name(suite_name):
35    """Canonicalize the suite's name.
36
37    @param suite_name: the name of the suite.
38    """
39    # Do not change this naming convention without updating
40    # site_utils.parse_job_name.
41    return 'test_suites/control.%s' % suite_name
42
43
44def _formatted_now():
45    """Format the current datetime."""
46    return datetime.datetime.now().strftime(time_utils.TIME_FMT)
47
48
49def make_builds_from_options(options):
50    """Create a dict of builds for creating a suite job.
51
52    The returned dict maps version label prefixes to build names. Together,
53    each key-value pair describes a complete label.
54
55    @param options: SimpleNamespace from argument parsing.
56
57    @return: dict mapping version label prefixes to build names
58    """
59    builds = {}
60    build_prefix = None
61    if options.build:
62        build_prefix = provision.get_version_label_prefix(options.build)
63        builds[build_prefix] = options.build
64
65    if options.cheets_build:
66        builds[provision.CROS_ANDROID_VERSION_PREFIX] = options.cheets_build
67        if build_prefix == provision.CROS_VERSION_PREFIX:
68            builds[build_prefix] += provision.CHEETS_SUFFIX
69
70    if options.firmware_rw_build:
71        builds[provision.FW_RW_VERSION_PREFIX] = options.firmware_rw_build
72
73    if options.firmware_ro_build:
74        builds[provision.FW_RO_VERSION_PREFIX] = options.firmware_ro_build
75
76    return builds
77
78
79def get_test_source_build(builds, **dargs):
80    """Get the build of test code.
81
82    Get the test source build from arguments. If parameter
83    `test_source_build` is set and has a value, return its value. Otherwise
84    returns the ChromeOS build name if it exists. If ChromeOS build is not
85    specified either, raise SuiteArgumentException.
86
87    @param builds: the builds on which we're running this suite. It's a
88                   dictionary of version_prefix:build.
89    @param **dargs: Any other Suite constructor parameters, as described
90                    in Suite.__init__ docstring.
91
92    @return: The build contains the test code.
93    @raise: SuiteArgumentException if both test_source_build and ChromeOS
94            build are not specified.
95
96    """
97    if dargs.get('test_source_build', None):
98        return dargs['test_source_build']
99
100    cros_build = builds.get(provision.CROS_VERSION_PREFIX, None)
101    if cros_build.endswith(provision.CHEETS_SUFFIX):
102        test_source_build = re.sub(
103                provision.CHEETS_SUFFIX + '$', '', cros_build)
104    else:
105        test_source_build = cros_build
106
107    if not test_source_build:
108        raise error.SuiteArgumentException(
109                'test_source_build must be specified if CrOS build is not '
110                'specified.')
111
112    return test_source_build
113
114
115def stage_build_artifacts(build, hostname=None, artifacts=[]):
116    """
117    Ensure components of |build| necessary for installing images are staged.
118
119    @param build image we want to stage.
120    @param hostname hostname of a dut may run test on. This is to help to locate
121        a devserver closer to duts if needed. Default is None.
122    @param artifacts A list of string artifact name to be staged.
123
124    @raises StageControlFileFailure: if the dev server throws 500 while staging
125        suite control files.
126
127    @return: dev_server.ImageServer instance to use with this build.
128    @return: timings dictionary containing staging start/end times.
129    """
130    timings = {}
131    # Ensure components of |build| necessary for installing images are staged
132    # on the dev server. However set synchronous to False to allow other
133    # components to be downloaded in the background.
134    ds = dev_server.resolve(build, hostname=hostname)
135    ds_name = ds.hostname
136    timings[constants.DOWNLOAD_STARTED_TIME] = _formatted_now()
137    try:
138        artifacts_to_stage = ['test_suites', 'control_files']
139        artifacts_to_stage.extend(artifacts if artifacts else [])
140        ds.stage_artifacts(image=build, artifacts=artifacts_to_stage)
141    except dev_server.DevServerException as e:
142        raise error.StageControlFileFailure(
143                "Failed to stage %s on %s: %s" % (build, ds_name, e))
144    timings[constants.PAYLOAD_FINISHED_TIME] = _formatted_now()
145    return ds, timings
146
147
148def get_control_file_by_build(build, ds, suite_name):
149    """Return control file contents for |suite_name|.
150
151    Query the dev server at |ds| for the control file |suite_name|, included
152    in |build| for |board|.
153
154    @param build: unique name by which to refer to the image from now on.
155    @param ds: a dev_server.DevServer instance to fetch control file with.
156    @param suite_name: canonicalized suite name, e.g. test_suites/control.bvt.
157    @raises ControlFileNotFound if a unique suite control file doesn't exist.
158    @raises NoControlFileList if we can't list the control files at all.
159    @raises ControlFileEmpty if the control file exists on the server, but
160                             can't be read.
161
162    @return the contents of the desired control file.
163    """
164    getter = control_file_getter.DevServerGetter.create(build, ds)
165    devserver_name = ds.hostname
166    # Get the control file for the suite.
167    try:
168        control_file_in = getter.get_control_file_contents_by_name(suite_name)
169    except error.CrosDynamicSuiteException as e:
170        raise type(e)('Failed to get control file for %s '
171                      '(devserver: %s) (error: %s)' %
172                      (build, devserver_name, e))
173    if not control_file_in:
174        raise error.ControlFileEmpty(
175            "Fetching %s returned no data. (devserver: %s)" %
176            (suite_name, devserver_name))
177    # Force control files to only contain ascii characters.
178    try:
179        control_file_in.encode('ascii')
180    except UnicodeDecodeError as e:
181        raise error.ControlFileMalformed(str(e))
182
183    return control_file_in
184
185
186def _should_batch_with(cf_getter):
187    """Return whether control files should be fetched in batch.
188
189    This depends on the control file getter and configuration options.
190
191    If cf_getter is a File system ControlFileGetter, the cf_getter will
192    perform a full parse of the root directory associated with the
193    getter. This is the case when it's invoked from suite_preprocessor.
194
195    If cf_getter is a devserver getter, this will look up the suite_name in a
196    suite to control file map generated at build time, and parses the relevant
197    control files alone. This lookup happens on the devserver, so as far
198    as this method is concerned, both cases are equivalent. If
199    enable_controls_in_batch is switched on, this function will call
200    cf_getter.get_suite_info() to get a dict of control files and
201    contents in batch.
202
203    @param cf_getter: a control_file_getter.ControlFileGetter used to list
204           and fetch the content of control files
205    """
206    return (ENABLE_CONTROLS_IN_BATCH
207            and isinstance(cf_getter, control_file_getter.DevServerGetter))
208
209
210def _get_cf_texts_for_suite_batched(cf_getter, suite_name):
211    """Get control file content for given suite with batched getter.
212
213    See get_cf_texts_for_suite for params & returns.
214    """
215    suite_info = cf_getter.get_suite_info(suite_name=suite_name)
216    files = list(suite_info.keys())
217    filtered_files = _filter_cf_paths(files)
218    for path in filtered_files:
219        yield path, suite_info[path]
220
221
222def _get_cf_texts_for_suite_unbatched(cf_getter, suite_name):
223    """Get control file content for given suite with unbatched getter.
224
225    See get_cf_texts_for_suite for params & returns.
226    """
227    files = cf_getter.get_control_file_list(suite_name=suite_name)
228    filtered_files = _filter_cf_paths(files)
229    for path in filtered_files:
230        yield path, cf_getter.get_control_file_contents(path)
231
232
233def _filter_cf_paths(paths):
234    """Remove certain control file paths.
235
236    @param paths: Iterable of paths
237    @returns: generator yielding paths
238    """
239    matcher = re.compile(r'[^/]+/(deps|profilers)/.+')
240    return (path for path in paths if not matcher.match(path))
241
242
243def get_cf_texts_for_suite(cf_getter, suite_name):
244    """Get control file content for given suite.
245
246    @param cf_getter: A control file getter object, e.g.
247        a control_file_getter.DevServerGetter object.
248    @param suite_name: If specified, this method will attempt to restrain
249                       the search space to just this suite's control files.
250    @returns: generator yielding (path, text) tuples
251    """
252    if _should_batch_with(cf_getter):
253        return _get_cf_texts_for_suite_batched(cf_getter, suite_name)
254    else:
255        return _get_cf_texts_for_suite_unbatched(cf_getter, suite_name)
256
257
258def parse_cf_text(path, text):
259    """Parse control file text.
260
261    @param path: path to control file
262    @param text: control file text contents
263
264    @returns: a ControlData object
265
266    @raises ControlVariableException: There is a syntax error in a
267                                      control file.
268    """
269    test = control_data.parse_control_string(
270            text, raise_warnings=True, path=path)
271    test.text = text
272    return test
273
274def parse_cf_text_process(data):
275    """Worker process for parsing control file text
276
277    @param data: Tuple of path, text, forgiving_error, and test_args.
278
279    @returns: Tuple of the path and test ControlData
280
281    @raises ControlVariableException: If forgiving_error is false parsing
282                                      exceptions are raised instead of logged.
283    """
284    path, text, forgiving_error, test_args = data
285
286    if test_args:
287        text = tools.inject_vars(test_args, text)
288
289    try:
290        found_test = parse_cf_text(path, text)
291    except control_data.ControlVariableException as e:
292        if not forgiving_error:
293            msg = "Failed parsing %s\n%s" % (path, e)
294            raise control_data.ControlVariableException(msg)
295        logging.warning("Skipping %s\n%s", path, e)
296    except Exception as e:
297        logging.error("Bad %s\n%s", path, e)
298        import traceback
299        logging.error(traceback.format_exc())
300    else:
301        return (path, found_test)
302
303
304def get_process_limit():
305    """Limit the number of CPUs to use.
306
307    On a server many autotest instances can run in parallel. Avoid that
308    each of them requests all the CPUs at the same time causing a spike.
309    """
310    return min(8, multiprocessing.cpu_count())
311
312
313def parse_cf_text_many(control_file_texts,
314                       forgiving_error=False,
315                       test_args=None):
316    """Parse control file texts.
317
318    @param control_file_texts: iterable of (path, text) pairs
319    @param test_args: The test args to be injected into test control file.
320
321    @returns: a dictionary of ControlData objects
322    """
323    tests = {}
324
325    control_file_texts_all = list(control_file_texts)
326    if control_file_texts_all:
327        # Construct input data for worker processes. Each row contains the
328        # path, text, forgiving_error configuration, and test arguments.
329        paths, texts = list(zip(*control_file_texts_all))
330        worker_data = list(zip(paths, texts, [forgiving_error] * len(paths),
331                           [test_args] * len(paths)))
332        pool = multiprocessing.Pool(processes=get_process_limit())
333        raw_result_list = pool.map(parse_cf_text_process, worker_data)
334        pool.close()
335        pool.join()
336
337        result_list = _current_py_compatible_files(raw_result_list)
338        tests = dict(result_list)
339
340    return tests
341
342
343def _current_py_compatible_files(control_files):
344    """Given a list of control_files, return a list of compatible files.
345
346    Remove blanks/ctrl files with errors (aka not python3 when running
347    python3 compatible) items so the dict conversion doesn't fail.
348
349    @return: List of control files filtered down to those who are compatible
350             with the current running version of python
351    """
352    result_list = []
353    for item in control_files:
354        if item:
355            result_list.append(item)
356        elif six.PY2:
357            # Only raise the error in python 2 environments, for now. See
358            # crbug.com/990593
359            raise error.ControlFileMalformed(
360                "Blank or invalid control file. See log for details.")
361    return result_list
362
363
364def retrieve_control_data_for_test(cf_getter, test_name):
365    """Retrieve a test's control file.
366
367    @param cf_getter: a control_file_getter.ControlFileGetter object to
368                      list and fetch the control files' content.
369    @param test_name: Name of test to retrieve.
370
371    @raises ControlVariableException: There is a syntax error in a
372                                      control file.
373
374    @returns a ControlData object
375    """
376    path = cf_getter.get_control_file_path(test_name)
377    text = cf_getter.get_control_file_contents(path)
378    return parse_cf_text(path, text)
379
380
381def retrieve_for_suite(cf_getter, suite_name='', forgiving_error=False,
382                       test_args=None):
383    """Scan through all tests and find all tests.
384
385    @param suite_name: If specified, retrieve this suite's control file.
386
387    @raises ControlVariableException: If forgiving_parser is False and there
388                                      is a syntax error in a control file.
389
390    @returns a dictionary of ControlData objects that based on given
391             parameters.
392    """
393    control_file_texts = get_cf_texts_for_suite(cf_getter, suite_name)
394    return parse_cf_text_many(control_file_texts,
395                              forgiving_error=forgiving_error,
396                              test_args=test_args)
397
398
399def filter_tests(tests, predicate=lambda t: True):
400    """Filter child tests with predicates.
401
402    @tests: A dict of ControlData objects as tests.
403    @predicate: A test filter. By default it's None.
404
405    @returns a list of ControlData objects as tests.
406    """
407    logging.info('Parsed %s child test control files.', len(tests))
408    tests = [test for test in six.itervalues(tests) if predicate(test)]
409    tests.sort(key=lambda t:
410               control_data.ControlData.get_test_time_index(t.time),
411               reverse=True)
412    return tests
413
414
415def name_in_tag_predicate(name):
416    """Returns predicate that takes a control file and looks for |name|.
417
418    Builds a predicate that takes in a parsed control file (a ControlData)
419    and returns True if the SUITE tag is present and contains |name|.
420
421    @param name: the suite name to base the predicate on.
422    @return a callable that takes a ControlData and looks for |name| in that
423            ControlData object's suite member.
424    """
425    return lambda t: name in t.suite_tag_parts
426
427
428def test_name_in_list_predicate(name_list):
429    """Returns a predicate that matches control files by test name.
430
431    The returned predicate returns True for control files whose test name
432    is present in name_list.
433    """
434    name_set = set(name_list)
435    return lambda t: t.name in name_set
436