blob: e590e798ef996e45af5cf613600ee98e48e41a14 [file] [log] [blame]
# Copyright 2018 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
Recipe module that wraps the testsharder tool, which searches a Fuchsia
build for test specifications and groups them into shards.
The tool assigns a unique name to each produced shard.
Testsharder tool:
https://fuchsia.googlesource.com/fuchsia/+/refs/heads/master/tools/integration/testsharder/cmd/
"""
import attr
from recipe_engine import recipe_api
def assert_list_of_strings(_, __, lst):
assert all(isinstance(item, basestring) for item in lst)
@attr.s(frozen=True)
class Test(object):
"""Represents a test defined within the Fuchsia project.
Args:
name (str): The name of the test.
label (str): The GN label for the test target.
os (str): The OS on which the test is intended to run.
package_url (str): The package URL for the test. This field only makes
sense for Fuchsia tests.
path (str): The path to the test. If unset, `command` must be set.
command (list(str)): The command used to invoke the test.
deps (list(str)): The runtime dependencies of the test given as
relative paths within the build directory. This field only makes sense
for Linux and Mac tests.
"""
name = attr.ib(validator=attr.validators.instance_of(basestring))
os = attr.ib(validator=attr.validators.in_(('fuchsia', 'linux', 'mac')))
# TODO(olivernewman): make label required
label = attr.ib('', validator=attr.validators.instance_of(basestring))
package_url = attr.ib('', validator=attr.validators.instance_of(basestring))
path = attr.ib('', validator=attr.validators.instance_of(basestring))
command = attr.ib(factory=list, validator=assert_list_of_strings)
deps = attr.ib(factory=list, validator=assert_list_of_strings)
@classmethod
def from_jsonish(cls, jsond):
"""Creates a new Test from a JSON-compatible dict."""
# TODO(olivernewman): We have to accept install_path and deps_file for
# backwards compatibility. Revert change
# Ibe606b6e62aad751cd2b43ab4df758d984dc34b7 after backwards compatibility
# is no longer needed.
deprecated_fields = {
'install_path': 'path',
'deps_file': 'deps',
}
for old_field, new_field in deprecated_fields.items():
if old_field in jsond:
jsond[new_field] = jsond.pop(old_field)
valid_kwargs = attr.fields_dict(cls)
kwargs = {k: v for k, v in jsond.items() if k in valid_kwargs}
return cls(**kwargs)
def render_to_jsonish(self):
"""Returns a JSON-compatible dict representing the Test.
The format follows the format of testsharder.Test found here:
https://fuchsia.googlesource.com/fuchsia/+/master/tools/integration/testsharder/test_spec.go
"""
ret = {
'name': self.name,
'label': self.label,
'os': self.os,
'path': self.path,
'install_path': self.path, # TODO(olivernewman): Delete
}
if self.command:
ret['command'] = self.command
if self.package_url:
ret['package_url'] = self.package_url
return ret
class Shard(object):
"""Represents a shard of several tests with one common environment."""
@classmethod
def from_jsonish(cls, jsond):
"""Creates a new Shard from a JSON-compatible Python dict."""
return cls(
name=jsond['name'],
tests=[Test.from_jsonish(test) for test in jsond['tests']],
dimensions=jsond['environment']['dimensions'],
service_account=jsond['environment'].get('service_account', ''),
deps=jsond.get('deps', ()),
netboot=jsond['environment'].get('netboot', False),
)
def __init__(self,
name,
tests,
dimensions,
service_account='',
deps=(),
netboot=False):
"""Initializes a Shard.
Args:
name (str): The name of the shard.
tests (seq[Test]): A sequence of tests.
dimensions (dict[str]str): A dictionary of Swarming dimensions, mapping
name to value.
service_account (str): A service account to be attached to a swarming
task when this shard is run.
deps (seq(str)): The runtime dependencies of the tests in the shard
given as relative paths within the build directory. This field
only makes sense for Linux and Mac tests.
netboot (bool): Whether to netboot instead of paving before running the
tests.
"""
assert isinstance(name, str)
self._name = name
self._tests = tests
self._dimensions = {}
for k, v in dimensions.iteritems():
assert isinstance(k, str)
self._dimensions[k] = str(v)
self._service_account = service_account
self._netboot = netboot
# TODO(fxbug.dev/10411): over the course of this bug, the testsharder will
# be refactored to have deps as a top-level field in the shard json.
# This synthesizes that for a soft transition; after it is completed, just
# set self._deps as deps.
aggregated_deps = set(deps)
if not deps:
for test in tests:
aggregated_deps.update(test.deps)
self._deps = tuple(aggregated_deps)
@property
def name(self):
"""The name (str) of the shard."""
return self._name
@property
def tests(self):
"""The list of tests (list(Test)) in the shard."""
return self._tests
@property
def dimensions(self):
"""The Swarming dimensions (dict[str]str) specified by the shard."""
return self._dimensions.copy()
def __getattr__(self, dimension):
"""Returns the value of a given dimension is set in the shard; else, None."""
return self._dimensions.get(dimension)
@property
def targets_fuchsia(self):
"""Returns whether the shard of tests is meant to run on or against fuchsia."""
return self.os not in ['Linux', 'Mac']
@property
def service_account(self):
"""Returns any service account (string) to be attached to a swarming
task when this shard is run. Empty if unset."""
return self._service_account
@property
def deps(self):
"""The runtime dependencies (tuple(str)) of the test.
The dependencies are given as relative paths within the build directory and
are only specified when test's OS is Linux or Mac."""
return self._deps
@property
def netboot(self):
"""Return whether to netboot instead of paving before running the tests."""
return self._netboot
def render_to_jsonish(self):
"""Returns a JSON-compatible dict representing the Shard.
The format follows the format os testsharder.Shard found here:
https://fuchsia.googlesource.com/fuchsia/+/master/tools/integration/testsharder/shard.go
"""
ret = {
'name': self.name,
'tests': [test.render_to_jsonish() for test in self.tests],
'environment': {
'dimensions': self.dimensions
},
'deps': self.deps,
}
if self.service_account:
ret['environment']['service_account'] = self.service_account
if self.netboot:
ret['environment']['netboot'] = self.netboot
return ret
class TestsharderApi(recipe_api.RecipeApi):
"""Module for interacting with the Testsharder tool.
The testsharder tool accepts a set of test specifications and produces
a file containing shards of execution.
"""
Test = Test
Shard = Shard
def __init__(self, *args, **kwargs):
super(TestsharderApi, self).__init__(*args, **kwargs)
self._testsharder_path = None
def execute(self,
step_name,
testsharder_path,
build_dir,
max_shard_size=None,
target_duration_secs=0,
mode=None,
multipliers=None,
output_file=None,
tags=()):
"""Executes the testsharder tool.
Args:
step_name (str): name of the step.
testsharder_path (Path): path to the testsharder tool.
build_dir (Path): path to a Fuchsia build directory root, in
which GN has been run (ninja need not have been executed).
max_shard_size (long or None): Additional shards will be created if needed
to keep the number of tests per shard <= this number.
target_duration_secs (int): If >0, testsharder will try to produce
shards of approximately this duration.
mode (str or None): mode in which the testsharder may run (e.g.,
normal or restricted); if None, the default normal mode will
be used.
See https://fuchsia.googlesource.com/fuchsia/+/master/tools/integration/testsharder/mode.go
for more details
multipliers (Path or None): path of a json manifest specifying tests to run
multiple times; if supplied, new shards will be created to run them.
output_file (Path): optional file path to leak output to.
tags (list(str)): tags on which to filter test specs.
Returns:
A list of Shards, each representing one test shard.
"""
cmd = [
testsharder_path,
'-build-dir',
build_dir,
'-output-file',
self.m.json.output(leak_to=output_file),
]
if max_shard_size:
cmd += ['-max-shard-size', max_shard_size]
if target_duration_secs:
cmd += ['-target-duration-secs', target_duration_secs]
if multipliers:
cmd += ['-multipliers', multipliers]
for tag in tags:
cmd += ['-tag', tag]
if mode:
cmd += ['-mode', mode]
result = self.m.step(step_name, cmd).json.output
return [Shard.from_jsonish(shard) for shard in result]