blob: 1449f7b18e6b690f744d4a02581ecc7fdec61aa4 [file] [log] [blame]
# Copyright 2020 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.
"""Code for testing recipes."""
import datetime
import fnmatch
from google.protobuf import json_format
from google.protobuf import timestamp_pb2
from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2
from PB.go.chromium.org.luci.buildbucket.proto import (
builds_service as builds_service_pb2,
)
from PB.recipe_modules.fuchsia.recipe_testing import properties as properties_pb2
from recipe_engine import recipe_api
from RECIPE_MODULES.fuchsia.swarming_retry import api as swarming_retry_api
from RECIPE_MODULES.fuchsia.utils import memoize
class Build(swarming_retry_api.LedTask):
def include_cl(self, cl):
self._led_data = self._led_data.then("edit-cr-cl", cl)
def include_recipe_bundle(self):
self._led_data = self._led_data.then("edit-recipe-bundle")
def set_recipe_testing_properties(self, properties):
self._led_data = self._led_data.then(
"edit",
"-pa",
"$fuchsia/recipe_testing=%s" % json_format.MessageToJson(properties),
)
def led_task_name(builder):
"""Returns the name to use for the led swarming task for the builder."""
return "recipes-cq:%s" % builder
class RecipeTestingApi(recipe_api.RecipeApi):
"""API for running tests and processing test results."""
def __init__(self, props, *args, **kwargs):
super(RecipeTestingApi, self).__init__(*args, **kwargs)
self._recipe_depth = props.recipe_depth
self.enabled = props.enabled
self.max_build_age_seconds = int(datetime.timedelta(days=28).total_seconds())
self.projects = ("fuchsia",)
def _get_affected_recipes(self, recipes_path):
"""Collect affected recipes.
For now assume we care about all recipes.
"""
with self.m.step.nest("get_affected_recipes") as parent_step:
recipes_dir = recipes_path.join("recipes")
recipe_files = self.m.file.listdir(
"ls-recipes", recipes_dir, recursive=True
)
all_recipes = []
for recipe_file in recipe_files:
path = self.m.path.relpath(
self.m.path.realpath(recipe_file), self.m.path.realpath(recipes_dir)
)
# Files inside folders that end in ".resources" are never recipes.
if self.m.path.dirname(path).endswith(".resources"):
continue
name, ext = self.m.path.splitext(path)
if ext == ".py":
all_recipes.append(name)
parent_step.logs["all recipes"] = all_recipes
with self.m.context(cwd=recipes_path):
changed_files = self.m.git.get_changed_files(commit="HEAD")
parent_step.logs["changed files (raw)"] = changed_files
def is_expected_json(path):
# We want to ignore expected JSON files--they won't affect how recipes
# run. It's possible there are JSON files used as data for recipes
# instead of as expected test outputs, so determine which files to
# ignore very narrowly.
return self.m.path.splitext(path)[1] == ".json" and self.m.path.dirname(
path
).endswith(".expected")
def is_python_test_file(path):
"""Return True if this is a test file that we should ignore."""
# We want to ignore test_api.py files--they won't affect how recipes
# run in led, they only affect how recipes run in
# './recipes test run', and we test that every time any recipe is
# changed.
if (
self.m.path.basename(path) == "test_api.py"
and self.m.path.dirname(self.m.path.dirname(path))
== "recipe_modules"
):
return True
# Also ignore test definitions themselves. By convention these are
# given the filename 'full.py' in Fuchsia, but there is no
# guarantee this will remain the case.
test_dir_names = ("tests", "examples")
if (
self.m.path.splitext(path)[1] == ".py"
and self.m.path.basename(self.m.path.dirname(path))
in test_dir_names
):
return True
return False
def is_ignored_file(path):
return is_expected_json(path) or is_python_test_file(path)
filtered_changed_files = [
x for x in changed_files if not is_ignored_file(x)
]
parent_step.logs["changed files (filtered)"] = filtered_changed_files or [
"no changed files"
]
res = self.m.step(
"recipes-analyze",
[
recipes_path.join("recipes.py"),
"analyze",
self.m.json.input(
{"recipes": all_recipes, "files": filtered_changed_files}
),
self.m.json.output(),
],
)
affected_recipes = res.json.output["recipes"]
def should_test_all_recipes(path):
globs = (
"infra/config/recipes.cfg",
# We particularly care about running CQ for fuchsia.proto changes.
"recipe_proto/*.proto",
)
return any(fnmatch.fnmatch(path, glob) for glob in globs)
special_changed_files = [
f for f in changed_files if should_test_all_recipes(f)
]
if special_changed_files:
step = self.m.step("mark all recipes as affected", None)
step.presentation.step_summary_text = (
"because these files were changed:"
)
step.presentation.step_text = "\n" + "\n".join(special_changed_files)
affected_recipes = all_recipes
parent_step.logs["affected recipes"] = affected_recipes
return affected_recipes
def _get_last_green_build(self, builder):
"""Returns the build proto for a builder's most recent successful build.
If no build younger than `self.max_build_age_seconds` is found, returns
None.
Args:
builder: builder protobuf object
"""
project, bucket, builder = builder.split("/")
# "infra" is not returned by default, so we have to specify it.
build = self.m.buildbucket_util.last_build(
project, bucket, builder, status=common_pb2.SUCCESS
)
if not build:
return None
age_seconds = self.m.time.time() - build.end_time.seconds
if age_seconds > self.max_build_age_seconds:
return None
return build
def _create_led_build(self, orig_build, selftest_cl):
builder = orig_build.builder
builder_lookup_name = "{project}/{bucket}:{builder}".format(
project=builder.project, bucket=builder.bucket, builder=builder.builder,
)
# Using `led get-builder` instead of `led get-build` ensures that we
# won't use an outdated version of the builder (e.g. with deprecated
# dimensions).
led_data = self.m.led("get-builder", builder_lookup_name)
# led reduces the priority of tasks by 10 from their values in
# buildbucket which we do not want.
# TODO(crbug.com/1138533) Add an option to led to handle this.
led_data.result.buildbucket.bbagent_args.build.infra.swarming.priority -= 10
led_data = led_data.then("edit", "-name", led_task_name(builder.builder))
build = Build(api=self.m, name=builder.builder, led_data=led_data)
# Signal to the launched build that it's being tested by this module.
properties = properties_pb2.InputProperties(enabled=True)
if orig_build.input.properties["recipe"] == "recipes":
build.include_cl(selftest_cl)
properties.recipe_depth = self._recipe_depth + 1
elif orig_build.input.gerrit_changes:
orig_cl = orig_build.input.gerrit_changes[0]
cl_id, patchset = self._get_latest_cl(orig_cl.host, orig_cl.project)
# Setting the CL to a more recent CL helps avoid rebase errors, but
# if unable to find a recent CL, fall back to the original build's
# triggering CL. It usually works.
if not cl_id:
cl_id = orig_cl.change
patchset = orig_cl.patchset
url = "https://%s/c/%s/+/%d/%d" % (
orig_cl.host,
orig_cl.project,
cl_id,
patchset,
)
build.include_cl(url)
build.set_recipe_testing_properties(properties)
return build
@memoize
def _get_latest_cl(self, gerrit_host, project):
"""Returns number and patchset for a project's most recently landed CL.
Args:
gerrit_host (str): E.g., fuchsia-review.googlesource.com
project (str): The name of the project in gerrit, e.g. "fuchsia"
Returns:
A tuple of
* The integer change number for the CL corresponding to the commit at
the tip of the master branch.
* The last patchset of that CL.
"""
gitiles_host = gerrit_host.replace("-review", "")
remote = "https://%s/%s" % (gitiles_host, project)
log = self.m.gitiles.log(
remote, "refs/heads/master", limit=10, step_name="log %s" % project
)
for log_entry in log:
commit_hash = log_entry["id"]
step = self.m.gerrit.change_details(
"latest change details for %s" % project,
commit_hash,
query_params=("CURRENT_REVISION",),
host=gerrit_host,
test_data=self.m.json.test_api.output(
{
"_number": 12345,
"current_revision": "5" * 40,
"revisions": {"5" * 40: {"_number": 6}},
}
),
ok_ret=(0, 1),
)
# A commit that is committed directly without code review won't have a
# corresponding Gerrit CL, so fetching it will fail (which is fine, we'll
# just skip it and try the next one).
if step.retcode == 0:
cl_number = step.json.output["_number"]
rev = step.json.output["current_revision"]
ps_number = step.json.output["revisions"][rev]["_number"]
return (cl_number, ps_number)
return None, None
def run_lint(self, recipes_path, allowlist=""):
"""Run lint on recipes.
Args:
recipes_path (Path): The path to the root of the recipes repo.
allowlist (str): A regex of import names to allow.
"""
args = ["lint"]
if allowlist:
args.extend(["--whitelist", allowlist])
with self.m.context(cwd=recipes_path):
self.m.python("lint", self.m.context.cwd.join("recipes.py"), args=args)
def run_unit_tests(self, recipes_path):
"""Run the recipe unit tests."""
with self.m.context(cwd=recipes_path):
self.m.python(
"test", self.m.context.cwd.join("recipes.py"), args=["test", "run"]
)
def run_tests(
self,
recipes_path,
selftest_cl,
include_includable_only=False,
include_public=True,
include_private=False,
use_buildbucket=False,
selftest_builder=None,
):
"""Launch CQ builders.
Args:
recipes_path (Path): Path to recipes repo checkout.
selftest_cl (str): The CL to use to test a recursive recipe testing
invocation.
include_includable_only (bool): Include includable_only tryjobs.
include_public (bool): Include public tryjobs.
include_private (bool): Include private tryjobs.
use_buildbucket (bool): Whether to use Buildbucket for tryjobs
instead of led.
selftest_builder (str|None): Builder to use to guarantee that we
exercise the scheduling codepath when `use_buildbucket` is True.
"""
# When run against a change to the recipes recipe, this is what the
# swarming task stack should look like:
#
# * recipes.py from current recipe bundle, run against current CL
# * recipes.py from current CL, run against SELFTEST_CL
# * cobalt.py from current CL, run against current CL
#
# This works, but in case something goes wrong we need to make sure we
# don't enter infinite recursion. We should never get to a third call to
# recipes.py, so if we do we should exit.
if self._recipe_depth >= 2:
raise self.m.step.InfraFailure("recursion limit reached")
builders = []
for project in self.projects:
builders.extend(
self.m.commit_queue.all_tryjobs(
project=project,
include_includable_only=include_includable_only,
include_public=include_public,
include_private=include_private,
)
)
affected_recipes = self._get_affected_recipes(recipes_path=recipes_path)
if not affected_recipes:
return
if use_buildbucket:
self._run_buildbucket_tests(selftest_builder, builders, affected_recipes)
else:
self._run_led_tests(recipes_path, selftest_cl, builders, affected_recipes)
def _is_build_affected(self, orig_build, affected_recipes, presentation):
if not orig_build:
presentation.step_summary_text = "no recent builds found"
return False
recipe = orig_build.input.properties["recipe"]
assert recipe
is_recipe_affected = recipe in affected_recipes
presentation.step_summary_text = "SELECTED" if is_recipe_affected else "skipped"
presentation.logs["recipe_used"] = recipe
return is_recipe_affected
def _get_green_tryjobs(self, expiry_secs=24 * 60 * 60):
"""Return the set of tryjobs that are green on the current patchset.
Args:
expiry_secs (int): Do not return tryjobs which are older than this
value in seconds.
"""
builds = self.m.buildbucket.search(
builds_service_pb2.BuildPredicate(
gerrit_changes=list(self.m.buildbucket.build.input.gerrit_changes),
status=common_pb2.SUCCESS,
create_time=common_pb2.TimeRange(
start_time=timestamp_pb2.Timestamp(
seconds=int(self.m.time.time()) - expiry_secs,
),
),
),
fields=["builder"],
step_name="get green tryjobs",
)
return {self.m.buildbucket_util.full_builder_name(b.builder) for b in builds}
def _run_buildbucket_tests(self, selftest_builder, builders, affected_recipes):
affected_builders = []
recipes_is_affected = False
with self.m.step.nest("get builders") as nest:
green_tryjobs = self._get_green_tryjobs()
builders = [b for b in builders if b not in green_tryjobs]
for builder in builders:
with self.m.step.nest(builder) as presentation:
orig_build = self._get_last_green_build(builder)
if self._is_build_affected(
orig_build, affected_recipes, presentation
):
# With recipe versioning, the `recipes` recipe is
# already tested in this invocation, so don't schedule
# any more `recipes` builds.
if orig_build.input.properties["recipe"] == "recipes":
recipes_is_affected = True
continue
affected_builders.append(builder)
# If `affected_builders` is empty, but the current recipe was affected,
# then we should schedule one self-test builder so we can still exercise
# the scheduling codepath.
if not affected_builders and recipes_is_affected:
affected_builders = [selftest_builder]
with self.m.step.nest("launch builds") as presentation:
builds = self.m.subbuild.launch(
# TODO(atyfto): Fix subbuild.launch so it can accept builders
# with `bucket`s and/or `project`s which don't necessarily match
# the current build's.
builder_names=[b.split("/")[-1] for b in affected_builders],
presentation=presentation,
# Present tryjobs in Gerrit since they are effectively
# top-level builds.
hide_in_gerrit=False,
# Again, since these are effectively top-level builds, they
# should not inherit any properties of the parent.
pass_parent_properties=False,
)
with self.m.step.nest("collect builds") as presentation:
results = self.m.subbuild.collect(
build_ids=[b.build_id for b in builds.itervalues()],
presentation=presentation,
)
self.m.display_util.display_builds(
"check builds",
[b.build_proto for b in results.itervalues()],
raise_on_failure=True,
)
def _run_led_tests(self, recipes_path, selftest_cl, builders, affected_recipes):
builds = []
with self.m.step.nest("get builders") as nest, self.m.context(cwd=recipes_path):
for builder in builders:
with self.m.step.nest(builder) as presentation:
orig_build = self._get_last_green_build(builder)
if self._is_build_affected(
orig_build, affected_recipes, presentation
):
build = self._create_led_build(orig_build, selftest_cl)
build.include_recipe_bundle()
builds.append(build)
nest.step_summary_text = "selected {} builds".format(len(builds))
if not builds:
return
self.m.swarming_retry.run_and_present_tasks(builds)