blob: 7c1111b209c1b7d8e53acd1501db625a000d9b8c [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/main/tools/integration/testsharder/cmd/
"""
import re
import attr
from recipe_engine import recipe_api
@attr.s(frozen=True)
class Shard:
"""Represents an immutable shard of several tests with one common environment."""
# The name of the shard.
name = attr.ib(type=str)
# A sequence of dicts representing tests.
tests = attr.ib(converter=tuple)
# A dictionary of Swarming dimensions, mapping name to value.
_dimensions = attr.ib(
type=dict, converter=lambda d: {k: str(v) for k, v in d.items()}
)
# The execution timeout to set for the task that runs this shard.
timeout_secs = attr.ib(type=int, default=0)
# A service account to be attached to a swarming task when this shard is run.
service_account = attr.ib(type=str, default="")
# Whether to netboot instead of paving before running the tests.
netboot = attr.ib(type=bool, default=False)
# 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.
deps = attr.ib(factory=tuple, converter=tuple)
# The path to the shard specific package repository.
pkg_repo = attr.ib(type=str, default="")
# A dictionary containing the images to override in this shard.
image_overrides = attr.ib(factory=dict)
# A summary containing the test results of this shard. This is only
# populated if all the contained tests were skipped.
summary = attr.ib(factory=dict)
@classmethod
def from_jsonish(cls, jsond):
"""Creates a new Shard from a JSON-compatible Python dict."""
return cls(
name=jsond["name"],
tests=jsond["tests"],
dimensions=jsond["environment"]["dimensions"],
service_account=jsond["environment"].get("service_account", ""),
deps=jsond.get("deps", ()),
netboot=jsond["environment"].get("netboot", False),
timeout_secs=jsond.get("timeout_secs", 0),
pkg_repo=jsond.get("pkg_repo", ""),
image_overrides=jsond["environment"].get("image_overrides", {}),
summary=jsond.get("summary", {}),
)
@property
def dimensions(self):
"""The Swarming dimensions (dict[str]str) specified by the shard."""
return self._dimensions.copy()
@property
def os(self):
"""Returns the OS that the shard's tests should run on."""
return self._dimensions.get("os")
@property
def device_type(self):
"""Returns the device type used by the shard."""
return self._dimensions.get("device_type")
@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 should_skip(self):
"""Returns true if this shard contains a summary and should be skipped."""
if not self.summary.get("tests"):
return False
return True
@attr.s(frozen=True)
class TestModifier:
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.
"""
Shard = Shard
TestModifier = TestModifier
def execute(
self,
step_name,
testsharder_path,
build_dir,
max_shard_size=None,
target_duration_secs=0,
per_test_timeout_secs=0,
max_shards_per_env=0,
modifiers=None,
output_file=None,
tags=(),
use_affected_tests=False,
affected_tests=None,
affected_tests_multiply_threshold=0,
affected_tests_max_attempts=0,
affected_only=False,
ffx_deps=False,
image_deps=False,
hermetic_deps=False,
pave=False,
disabled_device_types=(),
skip_unaffected_tests=False,
per_shard_package_repos=False,
cache_test_packages=False,
):
"""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.
per_test_timeout_secs (int): Any test that executes for longer than
this will be considered failed.
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.
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 (list(str)): names of 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.
affected_only (bool): whether to only create shards for affected tests.
ffx_deps (bool): whether to include ffx deps in the shard deps.
image_deps (bool): whether to include image deps in the shard deps.
hermetic_deps (bool): whether to include all deps in the shard deps.
pave (bool): whether the shards should pave or netboot fuchsia.
disabled_device_types (list(str)): the device types to not return shards for.
skip_unaffected_tests (bool): whether to skip running unaffected tests.
per_shard_package_repos (bool): whether to generate per-shard package repos.
cache_test_packages (bool): whether to cache test packages in CAS.
Returns:
A list of Shards, each representing one test shard.
"""
logs = {}
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 per_test_timeout_secs:
cmd += ["-per-test-timeout-secs", per_test_timeout_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)]
logs["test_modifiers.json"] = self.m.json.dumps(input_json, indent=2)
for tag in tags:
cmd += ["-tag", tag]
if use_affected_tests:
affected_tests_text = "\n".join(affected_tests) + "\n"
cmd += [
"-affected-tests",
self.m.raw_io.input(affected_tests_text),
"-affected-tests-max-attempts",
affected_tests_max_attempts,
"-affected-tests-multiply-threshold",
affected_tests_multiply_threshold,
]
logs["affected_tests.txt"] = affected_tests_text
if affected_only:
cmd.append("-affected-only")
if skip_unaffected_tests:
cmd.append("-skip-unaffected")
if ffx_deps:
cmd.append("-ffx-deps")
if image_deps:
cmd.append("-image-deps")
if hermetic_deps:
cmd.append("-hermetic-deps")
if per_shard_package_repos:
cmd.append("-per-shard-package-repos")
if cache_test_packages:
cmd.append("-cache-test-packages")
if pave:
cmd.append("-pave")
step = self.m.step(
step_name,
cmd,
stderr=self.m.raw_io.output_text(add_output_log="on_failure"),
ok_ret="any",
)
step.presentation.logs.update(logs)
if step.retcode > 0:
step.presentation.status = self.m.step.FAILURE
if not step.stderr.strip(): # pragma: no cover
# In the unlikely event that there are no logs to explain the
# failure, emit a regular step failure error message.
self.m.step.raise_on_failure(step)
raise self.m.step.StepFailure(
"testsharder failed:\n\n%s"
% (
self.m.buildbucket_util.summary_message(
step.stderr,
truncation_message="(truncated, see %s stderr for details)"
% step_name,
)
)
)
shards = []
for s in step.json.output:
shard = Shard.from_jsonish(s)
if not shard.tests:
raise self.m.step.StepFailure(
"shard %s has no tests to run" % shard.name
)
if disabled_device_types and shard.device_type in disabled_device_types:
continue
shards.append(shard)
return shards
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 sorted(mod_map.values(), key=lambda m: m.name)
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 should_run_all_tests(self, commit_message):
"""Extracts the value of the "Run-All-Tests" footer.
Args:
commit_message (str): The commit message in which to search for the footer.
Returns:
True if we Run-All-Tests is set, False if not.
"""
run_all_tests_re = r"(?i)run-all-tests:\s*(true|false)"
match = re.search(run_all_tests_re, commit_message, re.MULTILINE)
if match and match.group(1).lower() == "true":
return True
return False
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. The
# question mark makes it non-greedy to prevent the colon preceding the
# optional run count from being considered part of the name.
(?P<name>[^,\(\s]+?)
\s*
# Optional "os" in parentheses
(
\(
\s*
(?P<os>\w*)
\s*
\)
)?
\s*
# Optional "total_runs" integer, preceded by a colon and any number of
# spaces.
(
:\s*
(?P<total_runs>\d+)
)?
""",
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.fullmatch(raw_multiplier)
if not match:
raise self.m.step.StepFailure(
"invalid multiplier %r" % raw_multiplier
)
multipliers.append(TestModifier(**match.groupdict()))
return multipliers