| # 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. |
| |
| import attr |
| |
| import collections |
| import datetime |
| |
| from recipe_engine import recipe_api |
| from PB.go.chromium.org.luci.buildbucket.proto import build as build_pb2 |
| from PB.go.chromium.org.luci.buildbucket.proto import common as common_pb2 |
| |
| from RECIPE_MODULES.fuchsia.utils import pluralize |
| |
| # As of 2021-10-19, the `api.buildbucket.collect()` timeout defaults to |
| # something too short. We never want subbuild collection to timeout; we'd rather |
| # the whole build time out. |
| COLLECT_TIMEOUT = datetime.timedelta(hours=24) |
| |
| # We implicitly rely on a select few properties being passed through |
| # from the parent build to the subbuild. |
| # NOTE: Only dynamically computed properties should be passed through |
| # automatically. Any static properties (i.e. properties that don't change |
| # between builds) should be configured explicitly on the subbuild's builder |
| # rather than implicitly passing them through. |
| PASS_THROUGH_PROPERTIES = { |
| # Used by toolchain recipes to pass custom toolchain info. |
| "$fuchsia/build", |
| # Used by toolchain recipes to pass commit info. |
| "$fuchsia/checkout", |
| # recipe_bootstrap may pass some properties to subbuilds via this field. |
| "$fuchsia/recipe_bootstrap", |
| # If a parent build is running under recipe testing, then the |
| # subbuild is as well, and it should be made aware of that. |
| "$fuchsia/recipe_testing", |
| # Needed for subbuilds to determine how the parent was invoked in CQ |
| # (e.g. whether it's a dry run or full run). |
| "$recipe_engine/cq", |
| # Set by recipe_bootstrap. A subbuild should inherit the same |
| # integration base revision as its parent so the two builds use the same |
| # version of recipes and properties. |
| "integration_base_revision", |
| # Set by `led edit-recipe-bundle -property-only` to point to the uploaded |
| # recipe bundle. It's then up to recipe_bootstrap to download the recipe |
| # bundle and execute it. A led subbuild should use the same recipe bundle |
| # version as its parent. |
| "led_cas_recipe_bundle", |
| } |
| |
| |
| @attr.s |
| class SubbuildResult: |
| """Subbuild result metadata.""" |
| |
| builder = attr.ib(type=str) |
| build_id = attr.ib(type=str, converter=str) |
| url = attr.ib(type=str, default=None) |
| build_proto = attr.ib(type=build_pb2.Build, default=None) |
| |
| |
| @attr.s |
| class _SplitBuilder: |
| """Project/bucket/name triplet.""" |
| |
| project = attr.ib(type=str) |
| bucket = attr.ib(type=str) |
| name = attr.ib(type=str) |
| |
| |
| class SubbuildApi(recipe_api.RecipeApi): |
| """API for launching subbuilds and collecting the results.""" |
| |
| def launch( |
| self, |
| builder_names, |
| presentation, |
| extra_properties=None, |
| set_swarming_parent_run_id=True, |
| hide_in_gerrit=True, |
| include_sub_invs=True, |
| ): |
| """Launches builds with buildbucket or led. |
| |
| If the current task was launched with led, then subbuilds will also be |
| launched with led. |
| |
| Args: |
| builder_names (list(str)): The names of the builders to launch. |
| presentation (StepPresentation): The presentation to add logs to. |
| extra_properties (dict): The extra set of properties to launch the |
| builders with. These will override the parent properties that will be |
| passed to the children by default. |
| set_swarming_parent_run_id (bool): Whether to set swarming parent run |
| ID on buildbucket builds. |
| hide_in_gerrit (bool): Hide buildbucket subbuilds in the Gerrit UI. |
| include_sub_invs (bool): Whether the parent should inherit ResultDB |
| test results from the child builds. |
| |
| Returns: |
| launched_builds (dict): The launched_builds is a map from builder name |
| to the corresponding SubbuildResult. |
| """ |
| parent_properties = self.m.properties.thaw() |
| properties = { |
| key: val |
| for key, val in parent_properties.items() |
| if key and key in PASS_THROUGH_PROPERTIES |
| } |
| if extra_properties: |
| properties.update(extra_properties) |
| |
| # If this task was launched by led, we launch the child with led as well. |
| # This lets us ensure that the parent and child use the same version of |
| # the recipes code. That is a requirement for testing, as well as for |
| # avoiding the need to do soft transitions when updating the interface |
| # between the parent and child recipes. |
| if self.m.led.launched_by_led: |
| builds = self._launch_with_led(builder_names, properties) |
| else: |
| builds = self._launch_with_buildbucket( |
| builder_names, |
| properties, |
| set_swarming_parent_run_id=set_swarming_parent_run_id, |
| hide_in_gerrit=hide_in_gerrit, |
| include_sub_invs=include_sub_invs, |
| ) |
| for builder, build in builds.items(): |
| presentation.links[builder] = build.url |
| return builds |
| |
| def split_builder(self, builder_name): |
| """Split a builder name into parts, filling in from current build.""" |
| parent = self.m.buildbucket.build.builder |
| |
| *prefixes, name = builder_name.split("/") |
| assert len(prefixes) <= 2, f"bad builder_name {builder_name}" |
| if len(prefixes) == 2: |
| project, bucket = prefixes |
| elif len(prefixes) == 1: |
| project = parent.project |
| bucket = prefixes[0] |
| else: |
| project = parent.project |
| bucket = parent.bucket |
| |
| return _SplitBuilder(project, bucket, name) |
| |
| def _launch_with_led(self, builder_names, properties): |
| edit_args = [] |
| for k, v in sorted(properties.items()): |
| edit_args.extend(["-p", f"{k}={self.m.json.dumps(v)}"]) |
| edit_cr_cl_arg = None |
| bb_input = self.m.buildbucket.build_input |
| if bb_input.gerrit_changes: |
| gerrit_change = bb_input.gerrit_changes[0] |
| edit_cr_cl_arg = f"https://{gerrit_change.host}/c/{gerrit_change.project}/+/{int(gerrit_change.change)}" |
| |
| builds = {} |
| for builder_name in builder_names: |
| builder = self.split_builder(builder_name) |
| led_data = self.m.led( |
| "get-builder", |
| # By default, led reduces the priority of tasks from their |
| # values in buildbucket which we do not want. |
| "-adjust-priority", |
| "0", |
| f"{builder.project}/{builder.bucket}/{builder.name}", |
| ) |
| led_data = led_data.then("edit", *edit_args) |
| if edit_cr_cl_arg: |
| led_data = led_data.then("edit-cr-cl", edit_cr_cl_arg) |
| led_data = self.m.led.inject_input_recipes(led_data) |
| launch_res = led_data.then("launch", "-modernize") |
| task_id = launch_res.launch_result.task_id |
| build_url = f"https://ci.chromium.org/swarming/task/{task_id}?server={launch_res.launch_result.swarming_hostname}" |
| builds[builder_name] = SubbuildResult( |
| builder=builder_name, build_id=task_id, url=build_url |
| ) |
| return builds |
| |
| def _launch_with_buildbucket( |
| self, |
| builder_names, |
| properties, |
| set_swarming_parent_run_id, |
| hide_in_gerrit, |
| include_sub_invs, |
| ): |
| reqs = [] |
| swarming_parent_run_id = ( |
| self.m.swarming.task_id if set_swarming_parent_run_id else None |
| ) |
| bb_tags = {"skip-retry-in-gerrit": "subbuild"} |
| if hide_in_gerrit: |
| bb_tags["hide-in-gerrit"] = "subbuild" |
| for builder_name in builder_names: |
| builder = self.split_builder(builder_name) |
| reqs.append( |
| self.m.buildbucket.schedule_request( |
| project=builder.project, |
| bucket=builder.bucket, |
| builder=builder.name, |
| properties=properties, |
| swarming_parent_run_id=swarming_parent_run_id, |
| priority=None, # Leave unset to avoid overriding priority from configs. |
| tags=self.m.buildbucket.tags(**bb_tags), |
| ) |
| ) |
| |
| scheduled_builds = self.m.buildbucket.schedule( |
| reqs, step_name="schedule", include_sub_invs=include_sub_invs |
| ) |
| |
| builds = {} |
| for build in scheduled_builds: |
| build_url = f"https://ci.chromium.org/b/{build.id}" |
| builds[build.builder.builder] = SubbuildResult( |
| builder=build.builder.builder, build_id=build.id, url=build_url |
| ) |
| return builds |
| |
| def collect(self, build_ids, launched_by_led=None, extra_fields=frozenset()): |
| """Collects builds with the provided build_ids. |
| |
| Args: |
| build_ids (list(str)): The list of build ids to collect results for. |
| presentation (StepPresentation): The presentation to add logs to. |
| launched_by_led (bool|None): Whether the builds to collect were |
| launched by led. If None, then this value will be determined by |
| whether the current task was launched by led. |
| extra_fields (set): Extra fields to include in the buildbucket |
| response. |
| |
| Returns: |
| A map from build IDs to the corresponding SubbuildResult. |
| """ |
| if launched_by_led is None: |
| launched_by_led = self.m.led.launched_by_led |
| if launched_by_led: |
| builds = self._collect_from_led(build_ids) |
| else: |
| builds = self._collect_from_buildbucket(build_ids, extra_fields) |
| return collections.OrderedDict( |
| sorted(builds.items(), key=lambda item: (item[1].builder, item[0])) |
| ) |
| |
| def get_property(self, build_proto, property_name): |
| """Retrieve an output property from a subbuild's Build proto. |
| |
| Ensures a clear and unified missing property error message across all |
| builders that use this recipe module. |
| """ |
| try: |
| return build_proto.output.properties[property_name] |
| except ValueError: |
| raise self.m.step.InfraFailure( |
| f"Subbuild did not set the {property_name!r} output property" |
| ) |
| |
| def _collect_from_led(self, task_ids): |
| swarming_results = self.m.swarming.collect( |
| "collect", task_ids, output_dir=self.m.path["cleanup"] |
| ) |
| builds = {} |
| for result in swarming_results: |
| task_id = result.id |
| # Led launch ensures this file is present in the task root dir. |
| build_proto_path = result.output_dir.join("build.proto.json") |
| build_proto = self.m.file.read_proto( |
| "read build.proto.json", build_proto_path, build_pb2.Build, "JSONPB" |
| ) |
| builds[task_id] = SubbuildResult( |
| builder=build_proto.builder.builder, |
| build_id=task_id, |
| build_proto=build_proto, |
| ) |
| return builds |
| |
| def _collect_from_buildbucket(self, build_ids, extra_fields): |
| bb_fields = self.m.buildbucket.DEFAULT_FIELDS.union( |
| {"infra.swarming.task_id", "summary_markdown"} |
| ).union(extra_fields) |
| |
| builds = self.m.buildbucket.collect_builds( |
| [int(build_id) for build_id in build_ids], |
| interval=20, # Lower from default of 60 b/c we're impatient. |
| timeout=COLLECT_TIMEOUT, |
| step_name="collect", |
| fields=bb_fields, |
| ) |
| |
| failed_builds = [b for b in builds.values() if b.status != common_pb2.SUCCESS] |
| if failed_builds: |
| task_ids = [b.infra.swarming.task_id for b in failed_builds] |
| # Make sure task IDs are non-empty. |
| assert all(task_ids), task_ids |
| |
| # Wait for the underlying Swarming tasks to complete. The Swarming |
| # task for a Buildbucket build can take significantly longer to |
| # complete than the build itself due to post-processing outside the |
| # scope of the build's recipe (e.g. cache pruning). If the parent |
| # build and its Swarming task both complete before the subbuild's |
| # Swarming task finishes post-processing, then the subbuild's |
| # Swarming task will be killed by Swarming due to the parent being |
| # complete. |
| # |
| # That is actually working as intended. However, it's confusing for |
| # a subbuild to be marked as killed when the recipe actually exited |
| # normally; "killed" usually only happens for CQ builds, when a |
| # build is canceled by CQ because a new patchset of the triggering |
| # CL is uploaded. So it's convenient to have dashboards and queries |
| # ignore "killed" tasks. We use this workaround to ensure that |
| # failed subbuilds with long post-processing steps have time to |
| # complete and exit cleanly with a plain old "COMPLETED (FAILURE)" |
| # status. |
| # |
| # We only do this if the subbuild failed as a latency optimization. |
| # If all subbuilds passed, the parent will go on to do some more |
| # steps using the results of the subbuilds, leaving time for the |
| # subbuilds' tasks to complete asynchronously, so we don't want to |
| # block here while the tasks complete. |
| self.m.swarming.collect( |
| f"wait for {pluralize('task', task_ids)} to complete", task_ids |
| ) |
| return { |
| str(build.id): SubbuildResult( |
| builder=build.builder.builder, build_id=build.id, build_proto=build |
| ) |
| for build in builds.values() |
| } |