blob: 44873c52a07d00c68ee1f6d61c6ce1e28f5ccacf [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.
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()
}