blob: 357547259c5e3dcfcac799c2b007b7884ff3dec9 [file] [log] [blame]
# Copyright 2019 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.
# Pylint is not smart enough to infer the return type of methods with a custom
# property decorator like @cached_property, so we have to disable some spurious
# warnings from cached property accesses. See
# https://github.com/PyCQA/pylint/issues/3484
#
# pylint: disable=no-member, not-an-iterable, not-context-manager
# pylint: disable=unsubscriptable-object
import copy
import difflib
import attr
from google.protobuf import json_format as jsonpb
from recipe_engine import recipe_api
from .orchestration_inputs import _TestOrchestrationInputs
from PB.go.fuchsia.dev.fuchsia.tools.integration.fint.proto import (
context as context_pb2,
set_artifacts as fint_set_artifacts_pb2,
build_artifacts as fint_build_artifacts_pb2,
)
from RECIPE_MODULES.fuchsia.utils import cached_property, memoize
# `fint set` and `fint build`, respectively, will write these files to the
# artifact directory specified by the context spec.
FINT_SET_ARTIFACTS = "set_artifacts.json"
FINT_BUILD_ARTIFACTS = "build_artifacts.json"
# Manifests produced by the build.
CTS_ARTIFACTS_JSON = "cts_artifacts.json"
RBE_CONFIG_JSON = "rbe_config.json"
TOOL_PATHS_JSON = "tool_paths.json"
TRIAGE_SOURCES_JSON = "triage_sources.json"
# Name of BigQuery project and table for uploading artifacts.
BIGQUERY_PROJECT = "fuchsia-infra"
BIGQUERY_ARTIFACTS_DATASET = "artifacts"
# The private and authorized SSH keys pulled down in the checkout, relative to
# the fuchsia root.
CHECKOUT_AUTHORIZED_KEY = ".ssh/authorized_keys"
CHECKOUT_PRIVATE_KEY = ".ssh/pkey"
# The path to a public key used to sign release builds. Only set on release
# builders.
RELEASE_PUBKEY_PATH = "/etc/release_keys/release_key_pub.pem"
# The name of the public key file uploaded in release builds.
RELEASE_PUBKEY_FILENAME = "publickey.pem"
# Name of compdb generated by GN, it is expected in the root of the build
# directory.
GN_COMPDB_FILENAME = "compile_commands.json"
# Name of the log containing the build failure summary.
FAILURE_SUMMARY_LOG = "failure summary"
# Set as an output property and consumed by the
# go/cq-incremental-builder-monitor_dev dashboard.
BUILD_FAILED_PROPERTY = "build_failed"
class NoSuchTool(Exception):
def __init__(self, name, cpu, os):
super(NoSuchTool, self).__init__(
"no such tool in %s: (name=%r, cpu=%r, os=%r)"
% (TOOL_PATHS_JSON, name, cpu, os)
)
@attr.s
class _FuchsiaBuildResults(object):
"""Represents a completed build of Fuchsia.
Attributes:
archives: Mapping between the canonical name of an archive produced by the Fuchsia
build to the absolute path to that archive on the local disk.
checkout: The Fuchsia checkout.
build_dir: The directory where Fuchsia build artifacts may be found.
images: Mapping between the canonical name of an image produced by the Fuchsia
build to its path relative to the fuchsia build directory.
gn_results: GN gen step results
"""
_api = attr.ib()
checkout = attr.ib()
build_dir = attr.ib()
gn_results = attr.ib()
images = attr.ib(factory=list)
archives = attr.ib(factory=list)
_size_check_failed = attr.ib(init=False, default=False)
ninja_targets = attr.ib(factory=list)
zedboot_images = attr.ib(factory=list)
zbi_test_qemu_kernel_images = attr.ib(factory=dict)
fint_build_artifacts = attr.ib(
type=fint_build_artifacts_pb2.BuildArtifacts, default=None
)
def __deepcopy__(self, memodict):
# Shallow copy first.
new = copy.copy(self)
# Only images needs to be a real deep copy.
new.images = copy.deepcopy(new.images, memodict)
return new
@property
def set_metadata(self):
return self.gn_results.fint_set_artifacts.metadata
@property
def affected_tests(self):
"""Returns a list of names of tests affected by the current gerrit change."""
if self._api.build.can_exit_early_if_unaffected(self.checkout):
return self.fint_build_artifacts.affected_tests
return []
@property
def no_work(self):
"""Returns whether all testing can be skipped."""
if self._api.build.can_exit_early_if_unaffected(self.checkout):
return self.fint_build_artifacts.build_not_affected
return False
@property
def authorized_key(self):
return self.checkout.root_dir.join(CHECKOUT_AUTHORIZED_KEY)
@property
def private_key(self):
return self.checkout.root_dir.join(CHECKOUT_PRIVATE_KEY)
@cached_property
def _binary_sizes(self):
"""The binary sizes data produced by the build.
Returns:
Tuple of (sizes dict, stdout str).
"""
cmd = [
self.tool("size_checker"),
"--build-dir",
self.build_dir,
"--sizes-json-out",
self._api.json.output(),
]
check_sizes_result = self._api.step(
"size_checker",
cmd,
step_test_data=lambda: self._api.json.test_api.output({"component": 1}),
ok_ret="any",
stderr=self._api.raw_io.output(),
)
self._size_check_failed = bool(check_sizes_result.exc_result.retcode)
return check_sizes_result.json.output, check_sizes_result.stderr
@cached_property
def cts_artifacts(self):
"""The paths to CTS artifacts relative to the checkout root."""
relpaths = self._api.file.read_json(
"read cts artifacts manifest",
self.build_dir.join(CTS_ARTIFACTS_JSON),
test_data=["foo.far", "bar.far"],
)
return [
self._api.path.abs_to_path(
self._api.path.realpath(self.build_dir.join(relpath))
)
for relpath in relpaths
]
@cached_property
def generated_sources(self):
"""The paths to the generated source files relative to the checkout root."""
generated_sources = []
for path in self.gn_results.generated_sources:
try:
abspath = self._api.path.abs_to_path(
self._api.path.realpath(self.build_dir.join(path.lstrip("/")))
)
except ValueError: # pragma: no cover
raise self._api.step.StepFailure(
"Invalid path in generated_sources.json: %s" % path
)
self._api.path.mock_add_paths(abspath)
if self._api.path.exists(abspath):
generated_sources.append(
self._api.path.relpath(abspath, self.checkout.root_dir)
)
return generated_sources
@cached_property
def triage_sources(self):
"""The paths to the triage sources relative to the checkout root."""
return [
self._api.path.relpath(f, self.checkout.root_dir)
for f in self.gn_results.triage_sources
]
@property
def compdb_path(self):
return self.gn_results.compdb_path
def filtered_compdb(self, *args, **kwargs):
return self.gn_results.filtered_compdb(*args, **kwargs)
def tool(self, name, cpu="x64", os=None, **kwargs):
"""The path to a tool of the given name and cpu."""
return self.gn_results.tool(name, cpu, os, **kwargs)
def check_binary_sizes(self):
"""Checks that binary sizes are less than the maximum allowed.
Raises:
StepFailure if the size exceeds the maximum.
"""
# This property is read by the binary-size Gerrit plugin.
with self._api.step.nest("check binary sizes") as presentation:
sizes_json, size_checker_logs = self._binary_sizes
presentation.logs["size_checker logs"] = size_checker_logs
if sizes_json:
presentation.properties["binary_sizes"] = sizes_json
if self._size_check_failed:
presentation.logs["size_checker JSON output"] = sizes_json
raise self._api.step.StepFailure("binary size checks failed")
def upload(self, gcs_bucket, is_release_version=False, namespace=None):
"""Uploads artifacts from the build to Google Cloud Storage.
Args:
gcs_bucket (str): GCS bucket name to upload build results to.
is_release_version (bool): True if checkout is a release version.
namespace (str|None): A unique namespace for the GCS upload location;
if None, the current build's ID is used.
"""
assert gcs_bucket
with self._api.step.nest("upload build results"):
self._api.build._upload_build_results(
self, gcs_bucket, is_release_version, namespace
)
self._api.build._upload_package_snapshot(self, gcs_bucket, namespace)
def upload_tracing_data(self, gcs_bucket, namespace):
"""Uploads GN and ninja tracing results for this build to GCS.
Args:
gcs_bucket (str): GCS bucket name to upload build results to.
namespace (str|None): A unique namespace for the GCS upload location;
if None, the current build's ID is used.
"""
assert gcs_bucket
self._api.build._upload_tracing_data(self, gcs_bucket, namespace)
@attr.s(frozen=True)
class _GNResults(object):
"""_GNResults represents the result of a `gn gen` invocation in the fuchsia build.
It exposes the API of the build, which defines how one can invoke
ninja.
"""
_api = attr.ib()
build_dir = attr.ib()
fint_set_artifacts = attr.ib(
# Optional for convenience when writing tests. Production recipe code
# should always populate this field.
default=None,
# Proto objects are not hashable.
hash=False,
)
_compdb_cache = attr.ib(factory=dict, init=False)
# The following attributes are private because they are only intended to be
# used from within this recipe module, not by any recipes that use this
# recipe module.
_fint_path = attr.ib(default=None)
_fint_params_path = attr.ib(default=None)
_fint_context = attr.ib(default=None)
def __attrs_post_init__(self):
# Eagerly read in the tools manifest so that it always has the same
# step name regardless of when the caller first accesses the manifest.
self._tools # pylint: disable=pointless-statement
@property
def skip_build(self):
"""Whether it's safe to skip doing a full build."""
return self.fint_set_artifacts.skip_build
@property
def gn_trace_path(self):
"""The path to a GN trace file produced by `fint set`."""
return self.fint_set_artifacts.gn_trace_path
@cached_property
def generated_sources(self):
"""Returns the generated source files (list(str)) from the fuchsia build.
The returned paths are relative to the fuchsia build directory.
"""
return self._api.file.read_json(
"read generated sources",
self.build_dir.join("generated_sources.json"),
test_data=["generated_header.h"],
)
def tool(self, name, cpu="x64", os=None, mock_for_tests=True):
"""Returns the path to the specified tool provided from the tool_paths
manifest.
Args:
name (str): The short name of the tool, as it appears in
tool_paths.json (usually the same as the basename of the
executable).
cpu (str): The arch of the machine the tool will run on.
os (str): The OS of the machine the tool will run on.
mock_for_tests (bool): Whether to mock the tool info if it's not
found in tool_paths.json. Ignored in production, only
useful in recipe unit test mode for getting code coverage of
the missing tool code path.
"""
os = os or self._api.platform.name
try:
tool = self._tools[name, cpu, os]
except KeyError:
# If we're in recipe unit testing mode, just return some mock info
# for the tool if it's not in tool_paths.json. Requiring that every
# tool used by the recipe shows up in the mock tool_paths.json is a
# maintenance burden, since adding a dependency on a new tool also
# requires modifying the mock tool_paths.json. It would also
# create much more noise in expectation files.
if self._api.build._test_data.enabled and mock_for_tests:
tool = {"path": "%s_%s/%s" % (os, cpu, name)}
else:
raise NoSuchTool(name, cpu, os)
relpath = tool["path"]
try:
return self._api.path.abs_to_path(
self._api.path.realpath(self.build_dir.join(relpath))
)
except ValueError: # pragma: no cover
raise self._api.step.StepFailure(
"Invalid path in tool_paths.json: %s" % relpath
)
@cached_property
def _tools(self):
tools = {}
tool_paths_manifest = self._api.file.read_json(
"read tool_paths manifest",
self.build_dir.join(TOOL_PATHS_JSON),
test_data=[
{"name": "foo", "cpu": "x64", "os": "linux", "path": "linux_x64/foo"}
],
)
for tool in tool_paths_manifest:
key = (tool["name"], tool["cpu"], tool["os"])
assert key not in tools, (
"only one tool with (name, cpu, os) == (%s, %s, %s) is allowed" % key
)
tools[key] = tool
return tools
@cached_property
def triage_sources(self):
"""Returns the absolute paths of the files defined in the triage_sources
manifest."""
triage_sources_manifest = self._api.file.read_json(
"read triage_sources manifest",
self.build_dir.join(TRIAGE_SOURCES_JSON),
test_data=self._api.build.test_api.mock_triage_sources_manifest(),
)
return [
self._api.path.abs_to_path(
self._api.path.realpath(self.build_dir.join(source))
)
for source in triage_sources_manifest
]
@cached_property
def rbe_config_path(self):
"""Returns the checkout relative path to the RBE config specified in
the Fuchsia source tree."""
rbe_config_manifest = self._api.file.read_json(
"read rbe_config manifest",
self.build_dir.join(RBE_CONFIG_JSON),
test_data=[{"path": "../../path/to/rbe/config.cfg"}],
)
assert len(rbe_config_manifest) == 1
rbe_config_path = self._api.path.abs_to_path(
self._api.path.realpath(self.build_dir.join(rbe_config_manifest[0]["path"]))
)
return rbe_config_path
@property
def compdb_path(self):
return self.build_dir.join(GN_COMPDB_FILENAME)
def filtered_compdb(self, filters=()):
"""The path to a merged compilation database, filtered via the passed
filters."""
filters = tuple(filters) # Ensure hashability.
if filters not in self._compdb_cache:
self._compdb_cache[filters] = self._filtered_compdb(filters)
return self._compdb_cache[filters]
def _filtered_compdb(self, filters):
def keep_in_compdb(entry):
# Filenames are relative to the build directory, and the build
# directory is absolute.
build_dir = self._api.path.abs_to_path(entry["directory"])
abspath = self._api.path.realpath(
self._api.path.join(build_dir, entry["file"])
)
try:
path = self._api.path.abs_to_path(abspath)
except ValueError:
# This happens if `abspath` is not rooted in one of the path
# module's known paths, which is the case for paths in `/bin`,
# for example. That also implies that the path isn't in the
# build directory, so skip it.
return False
if build_dir.is_parent_of(path):
return False
segments = entry["file"].split(self._api.path.sep)
if any(bad_segments in segments for bad_segments in filters):
return False
return True
compdb_filtered = [entry for entry in self._compdb if keep_in_compdb(entry)]
compdb_path = self._api.path["cleanup"].join(GN_COMPDB_FILENAME)
self._api.file.write_json(
"write filtered compdb",
compdb_path,
compdb_filtered,
# This file is generally massive, so we don't want to upload it to
# Logdog because that would take ages.
include_log=False,
)
return compdb_path
@cached_property
def _compdb(self):
# Assumes that the current builder is configured to generate compdbs.
return self._api.file.read_json(
"read compdb",
self.compdb_path,
test_data=[
{
"directory": "[START_DIR]/out/not-default",
"file": "../../foo.cpp",
"command": "clang++ foo.cpp",
},
{
"directory": "[START_DIR]/out/not-default",
"file": "../../third_party/foo.cpp",
"command": "clang++ third_party/foo.cpp",
},
{
"directory": "[START_DIR]/out/not-default",
"file": "../../out/not-default/foo.cpp",
"command": "clang++ foo.cpp",
},
{
"directory": "[START_DIR]/out/not-default",
"file": "/bin/ln",
"command": "/bin/ln -s foo bar",
},
],
include_log=False,
)
@cached_property
def zbi_tests(self):
"""Returns the ZBI tests from the Fuchsia build directory."""
return self._api.file.read_json(
"read zbi test manifest",
self.build_dir.join("zbi_tests.json"),
test_data=[],
)
class FuchsiaBuildApi(recipe_api.RecipeApi):
"""APIs for building Fuchsia."""
FINT_PARAMS_PROPERTY = "fint_params"
FINT_PARAMS_PATH_PROPERTY = "fint_params_path"
NoSuchTool = NoSuchTool
def __init__(self, props, *args, **kwargs):
super(FuchsiaBuildApi, self).__init__(*args, **kwargs)
self._clang_toolchain = props.clang_toolchain
self._clang_toolchain_dir = ""
self._gcc_toolchain = props.gcc_toolchain
self._gcc_toolchain_dir = ""
self._rust_toolchain = props.rust_toolchain
self._rust_toolchain_dir = ""
self._emitted_ninja_duration = False
@property
def clang_toolchain_dir(self):
if not self._clang_toolchain_dir and self._clang_toolchain.version:
self._clang_toolchain_dir = self._download_toolchain(
"clang", self._clang_toolchain, "third_party/clang"
)
return self._clang_toolchain_dir
@clang_toolchain_dir.setter
def clang_toolchain_dir(self, value):
self._clang_toolchain_dir = value
@property
def gcc_toolchain_dir(self):
if not self._gcc_toolchain_dir and self._gcc_toolchain.version:
self._gcc_toolchain_dir = self._download_toolchain(
"gcc", self._gcc_toolchain, "third_party/gcc"
)
return self._gcc_toolchain_dir
@gcc_toolchain_dir.setter
def gcc_toolchain_dir(self, value):
self._gcc_toolchain_dir = value
@property
def rust_toolchain_dir(self):
if not self._rust_toolchain_dir and self._rust_toolchain.version:
self._rust_toolchain_dir = self._download_toolchain(
"rust", self._rust_toolchain, "third_party/rust"
)
return self._rust_toolchain_dir
@rust_toolchain_dir.setter
def rust_toolchain_dir(self, value):
self._rust_toolchain_dir = value
def with_options(
self,
checkout,
fint_params_path,
build_dir=None,
collect_coverage=False,
incremental=False,
sdk_id=None,
**kwargs
):
"""Builds Fuchsia from a Jiri checkout.
Depending on the fint parameters, may or may not build
Fuchsia-the-operating-system (i.e. the Fuchsia images).
Args:
checkout (CheckoutResult): The Fuchsia checkout result.
fint_params_path (str): The path, relative to the checkout root,
of a platform spec textproto to pass to fint.
build_dir (Path): The build output directory.
collect_coverage (bool): Whether to build for collecting
coverage.
incremental (bool): Whether or not to build incrementally.
sdk_id (str): If specified, set sdk_id in GN.
**kwargs (dict): Passed through to _build().
Returns:
A FuchsiaBuildResults, representing the build.
"""
with self.m.step.nest("build") as presentation, self.m.macos_sdk():
fint_params = self.m.file.read_text(
"read fint params",
checkout.root_dir.join(fint_params_path),
test_data='field: "value"',
)
presentation.logs["fint_params"] = fint_params
# Write the fint params file to output properties so it can be
# logged by the orchestrator build if we're running in subbuild
# mode.
presentation.properties[self.FINT_PARAMS_PROPERTY] = fint_params
# Write the fint params path to output properties so `fx repro`
# knows which file to use.
presentation.properties[self.FINT_PARAMS_PATH_PROPERTY] = fint_params_path
gn_results = self.gen(
checkout=checkout,
fint_params_path=fint_params_path,
build_dir=build_dir,
sdk_id=sdk_id,
collect_coverage=collect_coverage,
presentation=presentation,
)
# Upload tests.json to Logdog so it's available to aid in debugging.
self.m.file.read_text(
"read tests.json", gn_results.build_dir.join("tests.json")
)
if self.can_exit_early_if_unaffected(checkout) and gn_results.skip_build:
return None
return self._build(
checkout=checkout,
gn_results=gn_results,
presentation=presentation,
**kwargs
)
def gen(
self,
checkout,
fint_params_path,
build_dir=None,
sdk_id=None,
collect_coverage=False,
presentation=None,
):
"""Sets up and calls `gn gen`.
Args:
checkout (CheckoutApi.CheckoutResults): The checkout results.
fint_params_path (str): The path to a build params textproto to
pass to fint, relative to the checkout directory.
build_dir (Path): The build directory. This defaults to "out/not-default".
Note that changing the build dir will invalidate Goma caches so ideally
should be only used when Goma is disabled.
sdk_id (str): The current SDK ID
collect_coverage (bool): Whether to build for collecting coverage.
presentation (StepPresentation): Step to attach logs to.
Returns:
A _GNResults object.
"""
assert fint_params_path, "fint_params_path must be set"
if not build_dir:
# Some parts of the build require the build dir to be two
# directories nested underneath the checkout dir.
# We choose the path to be intentionally different from
# "out/default" because that is what most developers use locally and we
# want to prevent the build from relying on those directory names.
build_dir = checkout.root_dir.join("out", "not-default")
# with_options() normally handles setting up the macOS SDK, but we need
# to ensure it's set up here to support recipes that call `gen()`
# directly.
with self.m.macos_sdk(), self.m.context(cwd=checkout.root_dir):
context = self._fint_context(
checkout=checkout,
build_dir=build_dir,
sdk_id=sdk_id,
collect_coverage=collect_coverage,
)
fint_path = self._fint_path(checkout)
# We call this step `gn gen` even though it runs `fint set`
# to avoid confusion about when the recipe runs `gn gen`.
step = self._run_fint(
"gn gen",
"set",
fint_path,
checkout.root_dir.join(fint_params_path),
context,
)
# Artifacts should be produced even if some `fint set` steps
# failed, as the artifacts may contain useful logs.
artifacts = self.m.file.read_proto(
"read fint set artifacts",
self.m.path.join(context.artifact_dir, FINT_SET_ARTIFACTS),
fint_set_artifacts_pb2.SetArtifacts,
"JSONPB",
test_proto=fint_set_artifacts_pb2.SetArtifacts(
gn_trace_path=self.m.path.join(
context.artifact_dir, "mock-gn-trace.json"
),
use_goma=True,
enable_rbe=True,
metadata=dict(
board="boards/x64.gni",
optimize="debug",
product="products/core.gni",
target_arch="x64",
variants=["asan"],
),
),
)
# Log the full failure summary so the entire text is visible even if
# it's truncated in the summary markdown.
if artifacts.failure_summary and presentation:
presentation.logs[
FAILURE_SUMMARY_LOG
] = artifacts.failure_summary.splitlines()
if step.retcode:
if artifacts.failure_summary:
msg = self.m.buildbucket_util.summary_message(
artifacts.failure_summary,
"(failure summary truncated, see the '%s' log for "
"full failure details)" % FAILURE_SUMMARY_LOG,
)
else:
msg = "Unrecognized fint set failure, see stdout for details"
raise self.m.step.StepFailure(msg)
elif artifacts.failure_summary:
raise self.m.step.StepFailure(
"`fint set` emitted a failure summary but had a retcode of zero"
)
return self.gn_results(
build_dir,
artifacts,
fint_path=fint_path,
fint_params_path=checkout.root_dir.join(fint_params_path),
fint_context=context,
)
def _build(
self,
checkout,
gn_results,
presentation,
allow_dirty=False,
gcs_bucket=None,
stats_gcs_bucket=None,
upload_namespace=None,
timeout_secs=90 * 60,
):
"""Runs `fint build`.
Fint build consumes the GN build APIs from the build directory as
well as the fint params path to determine what targets to build.
Args:
checkout (CheckoutResult): The Fuchsia checkout result.
gn_results (_GNResults): GN gen results.
presentation (StepPresentation): Presentation to attach important
logs to.
allow_dirty (bool): Skip the ninja no op check.
gcs_bucket (str or None): A GCS bucket to upload crash reports
to.
stats_gcs_bucket (str): GCS bucket name to upload build stats to.
timeout_secs (str): The timeout for running `fint build`.
upload_namespace (str): The namespace within the build stats GCS
bucket to upload to.
Returns:
A FuchsiaBuildResults, representing the build.
"""
if gn_results.fint_set_artifacts.use_goma:
self.m.goma.set_path(self.m.path.dirname(gn_results.tool("goma_ctl")))
goma_context = self.m.goma.build_with_goma
else:
goma_context = self.m.nullcontext
if gn_results.fint_set_artifacts.enable_rbe:
self.m.rbe.set_path(
self.m.path.abs_to_path(
self.m.path.dirname(gn_results.tool("rewrapper"))
)
)
self.m.rbe.set_config_path(gn_results.rbe_config_path)
rbe_context = self.m.rbe
else:
rbe_context = self.m.nullcontext
context = copy.deepcopy(gn_results._fint_context)
context.skip_ninja_noop_check = allow_dirty
with goma_context(), rbe_context():
fint_build_step = self._run_fint(
step_name="ninja",
command="build",
fint_path=gn_results._fint_path,
static_path=gn_results._fint_params_path,
context=context,
timeout=timeout_secs,
)
# Artifacts should be produced even if some `fint build` steps failed,
# as the artifacts may contain useful logs that will help understand
# the cause of failure.
fint_build_artifacts = self.m.file.read_proto(
"read fint build artifacts",
self.m.path.join(context.artifact_dir, FINT_BUILD_ARTIFACTS),
fint_build_artifacts_pb2.BuildArtifacts,
"JSONPB",
test_proto=self.test_api.fint_build_artifacts_proto(
# This isn't super realistic because tests sometimes aren't
# included in the build graph, in which case there would never
# be any affected tests even in CQ with changed files. But it's
# simplest if we just configure the mock affected tests here.
affected_tests=["test1", "test2"] if checkout.changed_files() else [],
built_targets=["target1", "target2"],
built_images=[{"name": "foo", "type": "blk", "path": "foo.img"}],
),
)
# Log the full failure summary so the entire text is visible even if
# it's truncated in the summary markdown.
if fint_build_artifacts.failure_summary:
presentation.logs[
FAILURE_SUMMARY_LOG
] = fint_build_artifacts.failure_summary.splitlines()
if fint_build_artifacts.log_files:
with self.m.step.nest("read fint log files"):
for name, path in fint_build_artifacts.log_files.items():
presentation.logs[name] = self.m.file.read_text(
"read %s" % self.m.path.basename(path), path, include_log=False
).splitlines()
# Only emit the ninja duration once even if we build multiple times.
# The builders for which we care about tracking the Ninja duration all
# only build once. For builders like perfcompare that build twice,
# the second build is incremental, so it is very fast and its duration
# is less meaningful than the original build's duration.
if not self._emitted_ninja_duration:
presentation.properties[
"ninja_duration_seconds"
] = fint_build_artifacts.ninja_duration_seconds
self._emitted_ninja_duration = True
if stats_gcs_bucket and upload_namespace:
try:
self._upload_buildstats_output(
gn_results, stats_gcs_bucket, upload_namespace, fint_build_artifacts
)
except Exception as e:
self.m.step("upload buildstats failure", None).presentation.logs[
"exception"
] = str(e).splitlines()
if fint_build_step.retcode:
if fint_build_artifacts.failure_summary:
msg = self.m.buildbucket_util.summary_message(
fint_build_artifacts.failure_summary,
"(failure summary truncated, see the '%s' log for "
"full failure details)" % FAILURE_SUMMARY_LOG,
)
else:
msg = "Unrecognized fint build failure, see stdout for details"
with self.m.step.nest("clang-crashreports"), self.m.context(
infra_steps=True
):
self._upload_crash_report(
gn_results.build_dir, gcs_bucket, self.m.buildbucket.build.id
)
raise self.m.step.StepFailure(msg)
elif fint_build_artifacts.failure_summary:
raise self.m.step.StepFailure(
"`fint build` emitted a failure summary but had a retcode of zero"
)
# Recursively convert to a dict so that we don't have to deal with
# proto Struct objects, which have some annoying properties like
# not showing the missing key value when raising a KeyError.
artifacts_dict = jsonpb.MessageToDict(
fint_build_artifacts,
including_default_value_fields=True,
preserving_proto_field_name=True,
)
return self.build_results(
build_dir=gn_results.build_dir,
checkout=checkout,
gn_results=gn_results,
images=artifacts_dict["built_images"],
archives=artifacts_dict["built_archives"],
ninja_targets=artifacts_dict["built_targets"],
zedboot_images=artifacts_dict["built_zedboot_images"],
zbi_test_qemu_kernel_images=artifacts_dict["zbi_test_qemu_kernel_images"],
fint_build_artifacts=fint_build_artifacts,
)
def can_exit_early_if_unaffected(self, checkout):
"""Returns whether or not we can safely skip building (and testing)."""
return (
# No changed_files -> CI, which we always want to test. fint should
# also take this into account and never indicate that testing can be
# skipped if there are no changed files, but we add an extra check
# here to be safe.
checkout.changed_files()
# Changes to integration, in particular for both infra configs and
# jiri manifests, can affect any stage of the build but generally
# won't cause changes to the build graph.
and not checkout.contains_integration_patch
# Recipe changes can also impact all stages of a build, but recipes
# aren't included in the build graph.
and not self.m.recipe_testing.enabled
)
def gn_results(self, *args, **kwargs):
return _GNResults(self.m, *args, **kwargs)
def build_results(self, *args, **kwargs):
return _FuchsiaBuildResults(self.m, *args, **kwargs)
def download_test_orchestration_inputs(self, digest):
return _TestOrchestrationInputs.download(self.m, digest)
def test_orchestration_inputs_from_build_results(self, *args, **kwargs):
return _TestOrchestrationInputs.from_build_results(self.m, *args, **kwargs)
def test_orchestration_inputs_property_name(self, without_cl):
if without_cl:
return _TestOrchestrationInputs.DIGEST_PROPERTY_WITHOUT_CL
else:
return _TestOrchestrationInputs.DIGEST_PROPERTY
@memoize
def _fint_context(self, checkout, build_dir, sdk_id, collect_coverage):
"""Assembles a fint Context spec."""
return context_pb2.Context(
checkout_dir=str(checkout.root_dir),
build_dir=str(build_dir),
artifact_dir=str(self.m.path.mkdtemp("fint_artifacts")),
sdk_id=sdk_id or "",
changed_files=[
context_pb2.Context.ChangedFile(
path=self.m.path.relpath(path, checkout.root_dir)
)
for path in checkout.changed_files()
],
cache_dir=str(self.m.path["cache"]),
release_version=checkout.release_version,
clang_toolchain_dir=str(self.clang_toolchain_dir),
gcc_toolchain_dir=str(self.gcc_toolchain_dir),
rust_toolchain_dir=str(self.rust_toolchain_dir),
collect_coverage=collect_coverage,
goma_job_count=self.m.goma.jobs,
)
@memoize
def _fint_path(self, checkout):
"""Builds and returns the path to a fint executable."""
# TODO(olivernewman): Rebuild fint if the checkout hash changes (e.g.
# for perfcompare).
fint_path = self.m.path.mkdtemp("fint").join("fint")
bootstrap_path = checkout.root_dir.join("tools", "integration", "bootstrap.sh")
self.m.step("bootstrap fint", [bootstrap_path, "-o", fint_path])
return fint_path
def _run_fint(
self,
step_name,
command,
fint_path,
static_path,
context,
timeout=None,
**kwargs
):
context_textproto = self.m.proto.encode(context, "TEXTPB")
step = self.m.step(
step_name,
[
fint_path,
command,
"-static",
static_path,
"-context",
self.m.raw_io.input(context_textproto),
],
ok_ret="any",
timeout=timeout,
**kwargs
)
if step.retcode or step.exc_result.had_timeout:
step.presentation.status = self.m.step.FAILURE
step.presentation.properties[BUILD_FAILED_PROPERTY] = True
step.presentation.step_text = "run by `fint %s`" % command
step.presentation.logs["context.textproto"] = context_textproto
if step.exc_result.had_timeout:
timeout_string = "%d seconds" % timeout
if timeout > 60:
timeout_string = "%d minutes" % (timeout / 60)
raise self.m.step.StepFailure(
"`%s` timed out after %s." % (step_name, timeout_string)
)
return step
def _upload_crash_report(self, build_dir, gcs_bucket, build_id):
crashreports_dir = build_dir.join("clang-crashreports")
self.m.path.mock_add_paths(crashreports_dir)
if not gcs_bucket or not self.m.path.exists(crashreports_dir):
return # pragma: no cover
temp = self.m.path.mkdtemp("reproducers")
reproducers = self.m.file.glob_paths(
"find reproducers",
crashreports_dir,
"*.sh",
test_data=[crashreports_dir.join("foo.sh")],
)
for reproducer in reproducers:
base = self.m.path.splitext(self.m.path.basename(reproducer))[0]
files = self.m.file.glob_paths(
"find %s files" % base,
crashreports_dir,
base + ".*",
test_data=[
crashreports_dir.join("foo.sh"),
crashreports_dir.join("foo.cpp"),
],
)
tgz_basename = "%s.tar.gz" % base
tgz_path = temp.join(tgz_basename)
archive = self.m.tar.create(tgz_path, compression="gzip")
for f in files:
archive.add(f, crashreports_dir)
archive.tar("create %s" % tgz_basename)
self.m.upload.file_to_gcs(
source=tgz_path,
bucket=gcs_bucket,
subpath=tgz_basename,
namespace=build_id,
)
def _upload_buildstats_output(
self, gn_results, gcs_bucket, build_id, fint_build_artifacts
):
"""Runs the buildstats command for Fuchsia and uploads the output files to GCS."""
buildstats_binary_path = gn_results.tool("buildstats")
self.m.path.mock_add_paths(buildstats_binary_path)
if not self.m.path.exists(buildstats_binary_path): # pragma: no cover
# We might be trying to run buildstats after catching a build
# failure, in which case ninja may not even have gotten as far as
# building the buildstats tool.
raise Exception("The build did not produce the buildstats tool")
output_name = "fuchsia-buildstats.json"
output_path = self.m.path.mkdtemp("buildstats").join(output_name)
command = [
buildstats_binary_path,
"--ninjalog",
fint_build_artifacts.ninja_log_path,
"--compdb",
fint_build_artifacts.ninja_compdb_path,
"--graph",
fint_build_artifacts.ninja_graph_path,
"--output",
output_path,
]
with self.m.context(cwd=gn_results.build_dir):
self.m.step("fuchsia buildstats", command)
self.m.upload.file_to_gcs(
source=output_path,
bucket=gcs_bucket,
subpath=output_name,
namespace=build_id,
)
def _download_toolchain(self, name, toolchain, cipd_package):
"""Downloads a prebuilt toolchain from CAS or CIPD.
Args:
name (str): Name of the toolchain (e.g. "clang").
toolchain (CustomToolchain): Information about where to
download the toolchain from.
cipd_package (str): The name of the CIPD package to download if
toolchain_info["type"] is "cipd".
Returns: A Path to the root of a temporary directory where the
toolchain was downloaded.
"""
with self.m.step.nest("%s_toolchain" % name), self.m.context(infra_steps=True):
root_dir = self.m.path.mkdtemp(name)
if toolchain.source == "cipd":
pkgs = self.m.cipd.EnsureFile()
pkgs.add_package(
"fuchsia/%s/${platform}" % cipd_package, str(toolchain.version)
)
self.m.cipd.ensure(root_dir, pkgs)
elif toolchain.source == "isolated":
self.m.archive.download(
step_name="download",
digest=str(toolchain.version),
output_dir=root_dir,
)
else: # pragma: no cover
raise KeyError(
'%s_toolchain source "%s" not recognized' % (name, toolchain.source)
)
return root_dir
def _upload_build_results(
self, build_results, gcs_bucket, is_release_version, namespace
):
assert gcs_bucket
# Upload archives.
for archive in build_results.archives:
metadata = None
path = build_results.build_dir.join(archive["path"])
# Try and sign the build archive. If the we are on a release build and the
# signing script returns a signature, add it to the metadata and
# upload the public key for verification.
# TODO(fxb/51162): Remove once this is no longer used.
if is_release_version and archive["name"] == "archive":
signature = self._try_sign_archive(path)
if signature:
# Add the signature to the metadata.
metadata = {
"x-goog-meta-signature": signature,
}
# Upload the public key to GCS.
# Note that RELEASE_PUBKEY_PATH should always exist because a
# signature should only be generated if RELEASE_PUBKEY_PATH exists.
self.m.upload.file_to_gcs(
source=RELEASE_PUBKEY_PATH,
bucket=gcs_bucket,
subpath=RELEASE_PUBKEY_FILENAME,
namespace=namespace,
)
# Upload the archive
self.m.upload.file_to_gcs(
source=path,
bucket=gcs_bucket,
subpath=self.m.path.basename(path),
namespace=namespace,
metadata=metadata,
)
self._upload_binary_sizes(build_results)
self._upload_blobstats_output(build_results, gcs_bucket, namespace)
def _try_sign_archive(self, archive_path):
args = [
"--archive-file",
archive_path,
]
return self.m.python(
"run signing script",
self.resource("sign.py"),
args,
venv=self.resource("sign.py.vpython"),
stdout=self.m.raw_io.output(),
).stdout
def _upload_package_snapshot(self, build_results, gcs_bucket, build_id):
assert gcs_bucket
snapshot_path = build_results.build_dir.join(
"obj", "build", "images", "system.snapshot"
)
if not self.m.path.exists(snapshot_path):
return
# Upload a new table row for the system snapshot data generated during this build
snapshot = self.m.file.read_raw("read package snapshot file", snapshot_path)
build_packages_entry = {
"build_id": self.m.buildbucket.build.id,
"snapshot": snapshot,
}
basename = "system.snapshot.json"
build_packages_entry_file = self.m.path["tmp_base"].join(basename)
self.m.step(
"write build_packages_entry_file",
["cat", self.m.json.input(build_packages_entry)],
stdout=self.m.raw_io.output(leak_to=build_packages_entry_file),
)
self.m.upload.file_to_gcs(
source=build_packages_entry_file,
bucket=gcs_bucket,
subpath=basename,
namespace=build_id,
)
# Upload a new table row describing this particular build. Other tables' rows
# are linked into this table using the build id as a foreign key.
builds_entry = {
"bucket": self.m.buildbucket.bucket_v1,
"builder": self.m.buildbucket.builder_name,
"build_id": self.m.buildbucket.build.id,
"gitiles_commit": [self.m.buildbucket.gitiles_commit.id],
"datetime": str(self.m.buildbucket.build.create_time.ToDatetime()),
"start_time": str(self.m.buildbucket.build.start_time.ToDatetime()),
"repo": self.m.buildbucket.build_input.gitiles_commit.project,
"arch": build_results.set_metadata.target_arch,
"product": build_results.set_metadata.product,
"board": build_results.set_metadata.board,
"channel": [""],
}
self.m.bqupload.insert(
step_name="add table row: %s/%s/builds_beta"
% (BIGQUERY_PROJECT, BIGQUERY_ARTIFACTS_DATASET),
project=BIGQUERY_PROJECT,
dataset=BIGQUERY_ARTIFACTS_DATASET,
table="builds_beta",
rows=[builds_entry],
)
def _upload_binary_sizes(self, build_results):
"""Uploads size checks to BigQuery.
The upload also includes metadata about this build so that the
data can be used to create a self-contained BigQuery table.
"""
if not build_results._binary_sizes[0]: # pragma: no cover
return
metadata = {
"builder_name": self.m.buildbucket.builder_name,
# This field is set to a string in the BQ table schema because it's just
# an opaque ID. The conversion from int to string that happens on the
# BigQuery side is not what we want, so convert here.
"build_id": str(self.m.buildbucket.build.id),
"build_create_time_seconds": self.m.buildbucket.build.create_time.seconds,
"gitiles_commit_host": self.m.buildbucket.gitiles_commit.host,
"gitiles_commit_id": self.m.buildbucket.gitiles_commit.id,
"gitiles_commit_project": self.m.buildbucket.gitiles_commit.project,
}
rows = []
for component, item in build_results._binary_sizes[0].items():
# The size_checker output includes additional meta-data for
# binary sizes beyond just the size/budget values themselves. Some
# of this (e.g component owner URL) is encoded as strings, which
# do not fit with the BQ table schema for binary_sizes (and which
# we don't really want to persist anyway). Given that we are bound
# to the existing JSON structure (for compatibility with
# binary-sizes gerrit plugin) filter the non-conforming items out.
if isinstance(item, (int, float)):
row = metadata.copy()
row["component"] = component
row["size"] = item
rows.append(row)
self.m.bqupload.insert(
step_name="upload size_checker output",
project=BIGQUERY_PROJECT,
dataset=BIGQUERY_ARTIFACTS_DATASET,
table="binary_sizes",
rows=rows,
)
def _upload_blobstats_output(self, build_results, gcs_bucket, build_id):
"""Runs the blobstats command and uploads the output files to GCS."""
dir_name = "blobstats"
blobstats_output_dir = self.m.path["cleanup"].join(dir_name)
with self.m.context(cwd=build_results.build_dir):
result = self.m.step(
"blobstats",
[build_results.tool("blobstats"), "--output=%s" % blobstats_output_dir],
ok_ret="any",
)
# If blobstats failed, it's probably because the build intentionally
# didn't produce the input files that blobstats requires. Blobstats is
# generally just a nice-to-have anyway, so either way it's probably okay
# to silently continue without uploading results if blobstats fails.
if result.retcode != 0:
return
self.m.upload.directory_to_gcs(
source=blobstats_output_dir,
bucket=gcs_bucket,
subpath=dir_name,
namespace=build_id,
rsync=False,
)
def _upload_tracing_data(self, build_results, gcs_bucket, build_id):
"""Uploads GN and ninja tracing results for this build to GCS."""
paths_to_upload = []
if build_results.gn_results.gn_trace_path:
paths_to_upload += [
("fuchsia_gn_trace.json", build_results.gn_results.gn_trace_path),
]
with self.m.step.nest("extract ninja traces"):
paths_to_upload += [
(
"fuchsia_ninja_trace.json",
self._extract_ninja_tracing_data(
build_results, "fuchsia_ninja_trace.json"
),
),
]
with self.m.step.nest("upload traces") as presentation:
for filename, path in paths_to_upload:
step = self.m.upload.file_to_gcs(
source=path,
bucket=gcs_bucket,
subpath=filename,
namespace=build_id,
)
# Perfetto needs an unauthenticated URL.
# TODO(fxbug.dev/66249): Perfetto cannot load non-public traces.
# Consider hiding this link in such cases.
step.presentation.links[
"perfetto_ui"
] = "https://ui.perfetto.dev/#!?url=%s" % (
self.m.gsutil.unauthenticated_url(step.presentation.links[filename])
)
# This is shown as a workaround to hint users on how to load
# non-public traces.
presentation.links[
"fuchsia.dev guide"
] = "https://fuchsia.dev/fuchsia-src/development/tracing/tutorial/converting-visualizing-a-trace#html-trace"
def _extract_ninja_tracing_data(self, build_results, trace_name):
"""Extracts the tracing data by combining .ninjalog, compdb and graph.
Args:
build_results (_FuchsiaBuildResults): The results of the build.
build_dir (Path): The build output directory.
trace_name (str): The name of the trace file.
"""
trace = self.m.path.mkdtemp("ninja-trace").join(trace_name)
self.m.step(
"ninjatrace %s" % trace_name,
[
build_results.tool("ninjatrace"),
"-ninjalog",
build_results.fint_build_artifacts.ninja_log_path,
"-compdb",
build_results.fint_build_artifacts.ninja_compdb_path,
"-graph",
build_results.fint_build_artifacts.ninja_graph_path,
"-critical-path",
"-trace-json",
trace,
],
stdout=self.m.raw_io.output(leak_to=trace),
)
return trace