blob: 6b0b5db407ac6b0e1d5eb406246af3cf7e75de05 [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 re
import attr
from recipe_engine import recipe_api
# TODO(fxbug.dev/51896): The recipes shouldn't know the schema of tests.json.
# They should pass the JSON objects straight through with no deserialization.
class Test(object):
"""Represents a test defined within the Fuchsia project.
Args:
os (str): The OS on which the test is intended to run.
path (str): The path to the test. Only makes sense for Linux and Mac
tests.
other_fields (dict): Fields not used by the recipes that will be passed
straight through.
"""
def __init__(self, os, path="", **other_fields):
self.os = os
self.path = path
self._other_fields = other_fields
@classmethod
def from_jsonish(cls, jsond):
return cls(**jsond)
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.go
"""
ret = {"os": self.os, "path": self.path}
ret.update(self._other_fields)
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
self._deps = tuple(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
@attr.s(frozen=True)
class TestModifier(object):
name = attr.ib(type=str)
os = attr.ib(type=str, converter=lambda x: x or "", default="")
total_runs = attr.ib(type=int, converter=lambda x: int(x) if x else 0, default=0)
affected = attr.ib(type=bool, default=False)
max_attempts = attr.ib(type=int, default=1)
def render_to_jsonish(self):
return attr.asdict(self)
def merge(self, mod):
"""Returns a new TestModifier with merged fields from mod and self.
Except for `total_runs`, the same field cannot be set to different
non-default values for both modifiers. For `total_runs`, we pick the
larger value.
"""
fields = {}
for field in attr.fields(TestModifier):
v1 = getattr(self, field.name)
v2 = getattr(mod, field.name)
if field.name in ["total_runs", "max_attempts"]:
# A total_runs of 0 means to run as many times as will fit in one
# shard whereas a negative total_runs value means to not multiply it
# at all. Thus, we should take the value of 0 over a negative value.
fields[field.name] = max(v1, v2)
continue
assert not (
v1 and v2 and v1 != v2
), "multiple modifiers have conflicting values for the same test"
fields[field.name] = v1 or v2
return TestModifier(**fields)
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
TestModifier = TestModifier
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,
max_shards_per_env=0,
mode=None,
modifiers=None,
output_file=None,
tags=(),
use_affected_tests=False,
affected_tests_file=None,
affected_tests_multiply_threshold=0,
affected_tests_max_attempts=0,
):
"""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.
max_shards_per_env (int): Testsharder will limit each environment to
this many shards, regardless of the values of max_shard_size and
target_duration_secs. If 0, testsharder will use its hardcoded
default max; if <0, no max will be set.
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
modifiers (list(TestModifier)): the test modifiers specifying
tests to run multiple times or to label as affected tests; 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.
use_affected_tests (bool): whether or not to pass affected tests flags to testsharder.
affected_tests_file (Path): path to file containing affected tests.
affected_tests_multiply_threshold (int): if there are <= this many affected
tests, they'll be considered for multiplication.
affected_tests_max_attempts (int): max attempts for affected tests which are
not multiplied.
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 max_shards_per_env:
# TODO(10404): this conditional is only necessary for backwards
# compatibility; by only setting this flag when we want to use a value
# that's not the testsharder default, we can avoid breaking builds that
# run against old versions of fuchsia.git from before this flag was
# implemented. Once backwards compatibility is no longer needed and/or
# recipe versioning is complete, we can remove the default flag value
# from testsharder into the specs, and unconditionally set this flag.
cmd += ["-max-shards-per-env", max_shards_per_env]
if modifiers:
input_json = [
m.render_to_jsonish() for m in self._combine_modifiers(modifiers)
]
cmd += ["-modifiers", self.m.json.input(input_json)]
for tag in tags:
cmd += ["-tag", tag]
if mode:
cmd += ["-mode", mode]
if use_affected_tests:
cmd += [
"-affected-tests",
affected_tests_file,
"-affected-tests-max-attempts",
affected_tests_max_attempts,
"-affected-tests-multiply-threshold",
affected_tests_multiply_threshold,
]
try:
step = self.m.step(step_name, cmd, stdout=self.m.raw_io.output_text())
finally:
self.m.step.active_result.presentation.step_summary_text = (
self.m.step.active_result.stdout
)
return [Shard.from_jsonish(shard) for shard in step.json.output]
def _combine_modifiers(self, modifiers):
"""Combine modifiers so there is one per test name."""
mod_map = {}
for m in modifiers:
if m.name in mod_map:
mod_map[m.name] = mod_map[m.name].merge(m)
else:
mod_map[m.name] = m
return mod_map.itervalues()
def affected_test_modifiers(self, tests, max_attempts):
"""Returns TestModifiers for the given tests with the affected property set.
The total_runs should be set to a negative number so that the test
doesn't get counted as a multiplier.
"""
return [
TestModifier(
name=test, total_runs=-1, affected=True, max_attempts=max_attempts
)
for test in tests
]
def extract_multipliers(self, commit_message):
"""Extracts the value of the "Multiply/MULTIPLY" footer.
We support two multiply syntaxes:
1. Raw JSON:
Multiply: `[
{
"name": "test_name",
"os": "linux",
"total_runs": 123
}
]`
2. "Friendly":
Multiply: testsharder_tests (linux): 123, foo_tests (fuchsia): 5
Args:
commit_message (str): The commit message in which to search for the footer.
Returns:
A list of `TestModifier`, which will be empty if the commit message
doesn't contain the footer.
"""
multipliers = []
prefix = r"^\s*(MULTIPLY|Multiply)\s*[:=]\s*"
json_multiplier_regex = prefix + r"[\s\n]*`(?P<json>(.|\n)*?)`"
json_match = re.search(json_multiplier_regex, commit_message, re.MULTILINE)
if json_match:
multiplier_json = self.m.jsonutil.permissive_loads(json_match.group("json"))
for raw_multiplier in multiplier_json:
try:
multiplier = TestModifier(**raw_multiplier)
except (ValueError, TypeError):
raise self.m.step.StepFailure(
"invalid test multiplier: %s"
% self.m.json.dumps(raw_multiplier)
)
multipliers.append(multiplier)
return multipliers
friendly_multiplier_regex = re.compile(
r"""
# Required name. It cannot contain commas, spaces, or parentheses, and
# must not end with a colon.
\s*
(?P<name>[^,\(\s]+)(?<!:)
\s*
# Optional "os" in parentheses
(
\(
\s*
(?P<os>\w*)
\s*
\)
)?
\s*
# Optional "total_runs" integer, preceded by a colon and at least one
# space (to distinguish this colon from any colons in "name").
(
:\s+
(?P<total_runs>\d+)
)?
\s*
""",
re.VERBOSE,
)
for line in commit_message.splitlines():
line = line.strip()
footer_match = re.match(prefix, line)
if not footer_match:
continue
raw_multipliers = line[footer_match.end() :].strip(" ,").split(",")
for raw_multiplier in raw_multipliers:
raw_multiplier = raw_multiplier.strip()
match = friendly_multiplier_regex.match(raw_multiplier)
if not match:
raise self.m.step.StepFailure(
"invalid multiplier %r" % raw_multiplier
)
multipliers.append(TestModifier(**match.groupdict()))
return multipliers