| # Copyright 2022 The Chromium 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 |
| import functools |
| |
| from google.protobuf import json_format as jsonpb |
| 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.flutter.recipe_testing import properties as properties_pb2 |
| from recipe_engine import recipe_api |
| from RECIPE_MODULES.flutter.swarming_retry import api as swarming_retry_api |
| |
| # The maximum amount of builds to go back through in buildbucket to find |
| # a run that matches the current branch |
| MAX_BUILD_RESULTS = 25 |
| |
| # The default branch for a build if there is no branch set in a build found |
| # from searching buildbucket |
| DEFAULT_BRANCH = 'main' |
| |
| |
| class Build(swarming_retry_api.LedTask): |
| # This warning is spurious because LedTask defines _led_data. |
| # pylint: disable=attribute-defined-outside-init |
| |
| 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 use_realms(self): |
| self._led_data = self._led_data.then( |
| "edit", "-experiment", "luci.use_realms=true" |
| ) |
| |
| def set_properties(self, properties): |
| args = [] |
| for k, v in properties.items(): |
| args += ["-pa", "%s=%s" % (k, self._api.json.dumps(v))] |
| self._led_data = self._led_data.then("edit", *args) |
| |
| |
| class RecipeTestingApi(recipe_api.RecipeApi): |
| """API for running tests and processing test results.""" |
| |
| def __init__(self, props, *args, **kwargs): |
| super().__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 = ("flutter",) |
| |
| 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 flutter.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.empty("mark all recipes as affected") |
| 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 |
| |
| # Skip running recipes in the `recipes/contrib` directory, because |
| # they are generally lower-priority and not worth running by default |
| # in recipes CQ. |
| return {r for r in affected_recipes if not r.startswith("contrib/")} |
| |
| def _get_last_green_build(self, builder, cl_branch='main'): |
| """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. Also ensures that was returned build was run on the same branch |
| the current recipe is running on. |
| |
| Args: |
| builder: builder protobuf object |
| """ |
| project, bucket, builder = builder.split('/') |
| predicate = builds_service_pb2.BuildPredicate() |
| predicate.builder.project = project |
| predicate.builder.bucket = bucket |
| predicate.builder.builder = builder |
| predicate.status = common_pb2.SUCCESS |
| |
| builds = self.m.buildbucket.search(predicate, limit=MAX_BUILD_RESULTS) |
| |
| def built_on_branch(build, branch): |
| """Return True if build was built on the provided branch.""" |
| current_branch = \ |
| 'refs/heads/%s' % (branch or DEFAULT_BRANCH) |
| build_properties = \ |
| build.input.properties |
| if 'exe_cipd_version' in build_properties.keys(): |
| build_branch = build_properties['exe_cipd_version'] |
| else: |
| build_branch = 'refs/heads/%s' % DEFAULT_BRANCH |
| # Some recipes do not specify the branch, so in the case where the |
| # branch is None, ensure a match can still be found. |
| return build_branch in [current_branch, None] |
| |
| builds_with_current_branch = \ |
| list(filter( |
| lambda build: built_on_branch(build, cl_branch), builds |
| )) |
| |
| builds_with_current_branch.sort( |
| reverse=True, key=lambda build: build.start_time.seconds |
| ) |
| |
| if not builds_with_current_branch: |
| return None |
| |
| build = builds_with_current_branch[0] |
| 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 |
| # By default the priority is increased by 10 (resulting in a "lower" |
| # priority), but we want it to stay the same. |
| led_data = self.m.led( |
| "get-build", "-real-build", "-adjust-priority", "0", orig_build.id |
| ) |
| |
| build = Build(api=self.m, name=builder.builder, led_data=led_data) |
| |
| if orig_build.input.properties["recipe"] == "recipes": |
| build.include_cl(selftest_cl) |
| 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_properties(self._tryjob_properties()) |
| build.use_realms() |
| |
| return build |
| |
| @functools.lru_cache(maxsize=None) |
| 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., flutter-review.googlesource.com |
| project (str): The name of the project in gerrit, e.g. "flutter" |
| |
| Returns: |
| A tuple of |
| * The integer change number for the CL corresponding to the commit at |
| the tip of the main branch. |
| * The last patchset of that CL. |
| """ |
| gitiles_host = gerrit_host.replace("-review", "") |
| remote = "https://%s/%s" % (gitiles_host, project) |
| ref = self.m.git.get_default_remote_branch(remote) |
| log = self.m.gitiles.log( |
| remote, ref, 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(["--allowlist", allowlist]) |
| with self.m.context(cwd=recipes_path): |
| self.m.step( |
| "lint", |
| cmd=[self.m.context.cwd.join("recipes.py")] + args, |
| ) |
| |
| def run_unit_tests(self, recipes_path): |
| """Run the recipe unit tests.""" |
| with self.m.context(cwd=recipes_path): |
| self.m.step( |
| "test", |
| cmd=[ |
| self.m.context.cwd.join("recipes.py"), |
| "test", |
| "run", |
| ], |
| ) |
| |
| def run_tests( |
| self, |
| recipes_path, |
| selftest_cl, |
| config, |
| 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. |
| config (RecipeTesting proto): Many options for led/bb tests. |
| TODO(fxbug.dev/88439): Make this a formal proto under |
| recipe_modules/recipe_testing. |
| 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 = set() |
| for project in config.projects: |
| project_builders = set( |
| self.m.commit_queue.all_tryjobs( |
| project=project.name, |
| include_unrestricted=project.include_unrestricted, |
| include_restricted=project.include_restricted, |
| config_name=project.cq_config_name or "commit-queue.cfg", |
| ) |
| ) |
| |
| for excluded_bucket in project.excluded_buckets: |
| excluded_builders = set() |
| for builder in project_builders: |
| # Retrieve "<bucket>" from "<project>/<bucket>/<builder>". |
| bucket = builder.split("/")[1] |
| if bucket == excluded_bucket: |
| excluded_builders.add(builder) |
| |
| if excluded_builders: |
| project_builders -= excluded_builders |
| with self.m.step.nest( |
| "excluding {} builders from bucket {}/{}".format( |
| len(excluded_builders), |
| project.name, |
| excluded_bucket, |
| )) as pres: |
| pres.step_summary_text = "\n".join(sorted(excluded_builders)) |
| |
| builders.update(project_builders) |
| |
| builders = sorted(builders) |
| |
| affected_recipes = self._get_affected_recipes(recipes_path=recipes_path) |
| if not affected_recipes: |
| return |
| |
| cl_branch = self._get_current_merging_branch() |
| |
| if config.use_buildbucket: |
| self._run_buildbucket_tests( |
| selftest_builder, builders, affected_recipes, cl_branch |
| ) |
| else: |
| self._run_led_tests( |
| recipes_path, selftest_cl, builders, affected_recipes, cl_branch |
| ) |
| |
| 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, cl_branch |
| ): |
| affected_builders = [] |
| recipes_is_affected = False |
| |
| with self.m.step.nest("get builders"), self.m.context(infra_steps=True): |
| 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, cl_branch) |
| 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], |
| extra_properties=self._tryjob_properties(), |
| presentation=presentation, |
| # Present tryjobs in Gerrit since they are effectively |
| # top-level builds. |
| hide_in_gerrit=False, |
| ) |
| with self.m.step.nest("collect builds"): |
| results = self.m.subbuild.collect( |
| build_ids=[b.build_id for b in builds.values()], |
| ) |
| self.m.buildbucket_util.display_builds( |
| "check builds", |
| [b.build_proto for b in results.values()], |
| raise_on_failure=True, |
| ) |
| |
| def _run_led_tests( |
| self, recipes_path, selftest_cl, builders, affected_recipes, cl_branch |
| ): |
| builds = [] |
| with self.m.step.nest("get builders") as nest, self.m.context( |
| cwd=recipes_path, infra_steps=True): |
| for builder in builders: |
| with self.m.step.nest(builder) as presentation: |
| orig_build = self._get_last_green_build(builder, cl_branch) |
| 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) |
| |
| def _tryjob_properties(self): |
| """Properties that should be set on each launched tryjob.""" |
| props = properties_pb2.InputProperties( |
| # Signal to the launched build that it's being tested by this module. |
| enabled=True, |
| # Increment the recipe depth. This only has an effect on builds that |
| # use this module. |
| recipe_depth=self._recipe_depth + 1, |
| ) |
| return { |
| "$flutter/recipe_testing": |
| jsonpb.MessageToDict(props, preserving_proto_field_name=True) |
| } |
| |
| def _get_current_merging_branch(self): |
| """Returns the branch that the current CL is being merged into. |
| |
| If the buildset is not available in the recipe, then DEFAULT_BRANCH is |
| used. |
| """ |
| tags = self.m.buildbucket.build.tags |
| buildset_tag = list(filter(lambda tag: tag.key == 'buildset', tags)) |
| buildset_property = self.m.properties.get('buildset') |
| if not buildset_tag and not buildset_property: |
| return DEFAULT_BRANCH |
| else: |
| buildset = buildset_tag[0].value if buildset_tag else buildset_property |
| host, cl_number = buildset.split('/')[2:4] |
| cl_information = \ |
| self.m.gerrit_util.get_gerrit_cl_details(host, cl_number) |
| return cl_information.get('branch') |