blob: 57984dfbb7d2b99d9c810652e34996831add0fde [file] [log] [blame]
# Copyright 2022 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.
"""Generic continuous integration recipe for projects in the Fuchsia ecosystem.
TODO(fxbug.dev/112403): Complete the transition to bazel_build_test_upload.py.
As of now, we do not have plans to support repositories which do not use Bazel.
This recipe supports projects that need the following steps in their CI jobs:
1. Checkout code.
2. Download additional pinned dependencies not checked into version control.
Note that this additional download step will be subject to removal once it
can be handled by Git itself, per
http://go/git-superproject-source-control-for-fuchsia.
3. Build.
4. Upload build artifacts (images, blobs, drivers, etc.).
5. Run tests in parallel shards on separate machines.
6. Trigger tests in downstream projects, passing through the uploaded build
artifacts.
## Entrypoint commands
The recipe interacts with each project via a set of high-level entrypoint
commands (often shell scripts), configured via properties. Interacting with each
project only via these entrypoints has several benefits:
- Prevents project-specific implementation details from leaking into the recipe.
This keeps the recipe simple, and minimizes the need for soft transitions by
allowing project owners to make changes within the project without changing
the infrastructure.
- Ensures that continuous integration workflows can be easily run locally just
by running the same commands as the recipe.
- Enforces that each project's continuous integration workflow follows a
reasonable process that's consistent with Fuchsia's CI guidelines:
https://fuchsia.dev/fuchsia-src/contribute/governance/rfcs/0148_ci_guidelines
Some entrypoint commands run actions themselves (e.g. `build_command`). Other
commands generate output that instructs the recipe what actions to take, for
infrastructure-specific steps that are needed across many projects (e.g.
`gcs_manifest_command` generates a list of files to upload to GCS).
### Variable substitution
Some of the project entrypoints commands need to take inputs from the recipe for
values that can only be known at runtime. For example, a project's build command
generally requires the absolute path to an infrastructure-created build
directory as an argument so it knows where to place build outputs.
For any input parameters that must come from the recipe, the recipe supports
substitution of a small predefined set of variables used in the entrypoint
configuration properties. For example, a builder for a given project might set
its `build_command` property to something like ["scripts/build.sh",
"--build-dir", "{build_dir}"], where "{build_dir}" is a magic string that will
be replaced with the actual path to the build directory when the recipe runs the
command.
An alternative to variable substitution would be to require each entrypoint
executable to implement a certain set of flags. However, this would make each
project more coupled to the infrastructure, since the flag names would need to
match what the recipe uses, and each entrypoint's command-line argument parser
must support the flag format that the recipe expects. So the recipe should not
assume that any entrypoint commands support any specific flags or flag formats.
"""
from google.protobuf import json_format as jsonpb
from PB.recipes.fuchsia.build_test_upload import InputProperties
from PB.infra.build_test_upload.upload_manifest import CIPDUploadManifest
from PB.infra.build_test_upload.test_manifest import TestManifest
from RECIPE_MODULES.fuchsia.swarming_retry import api as swarming_retry_api
DEPS = [
"fuchsia/artifacts",
"fuchsia/buildbucket_util",
"fuchsia/cas_util",
"fuchsia/checkout",
"fuchsia/cipd_util",
"fuchsia/dpi",
"fuchsia/fxt",
"fuchsia/git",
"fuchsia/git_checkout",
"fuchsia/swarming_retry",
"recipe_engine/context",
"recipe_engine/json",
"recipe_engine/path",
"recipe_engine/properties",
"recipe_engine/proto",
"recipe_engine/raw_io",
"recipe_engine/step",
"recipe_engine/swarming",
]
PROPERTIES = InputProperties
def RunSteps(api, props):
# Projects should not assume they'll always be checked out at the same
# absolute path, so do the checkout in a random temporary directory.
checkout_dir = api.path.mkdtemp("checkout")
if props.jiri_manifest:
api.checkout.with_options(
path=checkout_dir,
manifest=props.jiri_manifest,
remote=props.remote,
project=props.jiri_project,
)
with api.context(cwd=checkout_dir):
git_revision = api.git.rev_parse(
"HEAD",
step_name="resolve HEAD",
step_test_data=lambda: api.raw_io.test_api.stream_output_text("abc123"),
)
else:
_, git_revision = api.git_checkout(props.remote, path=checkout_dir)
# Compute a semantic version that a project can use as a placeholder value
# for operations that require a version number, if the project does not have
# a formal release process.
#
# NOTE: This assumes that the repo at the checkout root is the source of
# truth for the entire checkout, which may not be the case for some
# Jiri-based checkouts where the source of truth is a nested repository.
with api.context(cwd=checkout_dir):
revision_count = api.git.rev_list_count(
"HEAD",
step_name="resolve placeholder version",
test_data="1",
)
placeholder_version = "0.0.0.%s" % revision_count
upload_namespace = api.buildbucket_util.id
# Any of these variables can be used as a format string in a command's
# configured arguments and it will be substituted for the actual value. For
# example, "--build-dir={build_dir}" in a command's args will be
# replaced with "--build-dir=/actual/path/to/build/dir".
build_dir = api.path.mkdtemp("build")
variables = {
"build_dir": build_dir,
"upload_namespace": upload_namespace,
"placeholder_version": placeholder_version,
}
def run_command(step_name, command_pb, **kwargs):
# Not all projects need all possible commands, so skip any un-configured
# commands.
if not command_pb.path:
return None
cmd = [checkout_dir.join(*command_pb.path.split("/"))]
for arg in command_pb.args:
# Perform variable substitutions.
cmd.append(arg.format(**variables))
return api.step(step_name, cmd, **kwargs)
# TODO(fxbug.dev/101594): These should be configured higher in the
# stack in one of {swarming_bot, bbagent, recipe_bootstrap}. Set them
# here for now to unblock isolation work (b/234060366).
xdg_env_vars = [
"HOME",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_HOME",
"XDG_STATE_HOME",
]
env = api.context.env
for env_var in xdg_env_vars:
env[env_var] = env.get("TMPDIR", str(api.path["cleanup"]))
with api.context(cwd=checkout_dir, env=env):
run_command("download extra dependencies", props.download_command)
run_command("build", props.build_command)
if props.test_manifest_command.path:
assert (
props.testing_pool
), "testing_pool must be set if test_manifest_command is set"
test_manifest = run_command(
"generate test manifest",
props.test_manifest_command,
stdout=api.proto.output(TestManifest, codec="JSONPB"),
).stdout
with api.step.nest("upload test dependencies"):
task_requests = create_task_requests(
api, test_manifest, testing_pool=props.testing_pool
)
run_tests(api, task_requests)
if props.gcs_manifest_command.path:
with api.step.nest("gcs upload"):
gcs_manifest = run_command(
"generate gcs manifest",
props.gcs_manifest_command,
stdout=api.json.output(),
).stdout
api.artifacts.gcs_bucket = props.gcs_bucket
api.artifacts.namespace = upload_namespace
api.artifacts.upload_from_manifest(
"upload from manifest",
api.json.input(gcs_manifest),
sign_artifacts=props.sign_artifacts,
)
for cipd_cmd in props.cipd_manifest_commands:
with api.step.nest("cipd upload %s" % cipd_cmd.package):
cipd_manifest = run_command(
"generate cipd manifest",
cipd_cmd.command,
stdout=api.proto.output(CIPDUploadManifest, codec="JSONPB"),
).stdout
cipd_upload_from_manifest(
api,
cipd_cmd.package,
cipd_manifest,
build_dir=build_dir,
repository=props.remote,
git_revision=git_revision,
)
if props.mos_upload_options.repo_hostname:
api.dpi.upload(
"upload to MOS-TUF repos",
build_dir=build_dir,
options=props.mos_upload_options,
)
if props.gcs_bucket and props.HasField("fxt_options"):
api.fxt.orchestrate_fxt_tests(
bucket=props.gcs_bucket,
namespace=upload_namespace,
options=props.fxt_options,
)
def dirnames(api, path):
"""Lists all parent directories of a path."""
dirname = api.path.dirname(path)
# When dirname() becomes a no-op it means we've reached the root.
if path == dirname:
return
yield dirname
yield from dirnames(api, dirname)
def create_task_requests(api, test_manifest, testing_pool):
root_dir = api.path.abs_to_path(test_manifest.root_dir)
task_requests = []
for test_group in test_manifest.test_groups:
original_runfiles = set(test_group.runfiles)
# TODO(crbug.com/1216363): Once the `cas` tool is capable of following
# symlinks we won't need to reconstruct the runfiles in a temporary
# directory and can instead upload the runfiles directly from
# `root_dir`.
tree = api.cas_util.hardlink_tree(api.path.mkdtemp("cas-inputs"))
for f in test_group.runfiles:
# Ignore a file if we're also linking in the full contents of one of
# its parent directories.
if any(d in original_runfiles for d in dirnames(api, f)):
continue
tree.register_link(root_dir.join(f), linkname=tree.root.join(f))
tree.create_links("create links")
cas_digest = api.cas_util.upload(
tree.root,
step_name="upload files for %s" % test_group.name,
)
request = api.swarming.task_request().with_name(test_group.name)
request = request.with_slice(
0,
request[0]
.with_command(list(test_group.command))
.with_relative_cwd(test_group.exec_dir)
.with_cas_input_root(cas_digest)
.with_dimensions(pool=testing_pool, **test_group.scheduling_dimensions)
.with_execution_timeout_secs(test_group.timeout_secs)
.with_io_timeout_secs(5 * 60)
.with_expiration_secs(60 * 60),
)
task_requests.append(request)
return task_requests
def run_tests(api, task_requests):
tasks = [Task(request, api) for request in task_requests]
api.swarming_retry.run_and_present_tasks(tasks, max_attempts=1)
class Task(swarming_retry_api.TriggeredTask):
pass
def cipd_upload_from_manifest(
api, cipd_package, cipd_manifest, build_dir, repository, git_revision
):
staging_dir = api.path.mkdtemp("cipd")
tree = api.cas_util.hardlink_tree(staging_dir)
for f in cipd_manifest.files:
# We should generally not be uploading artifacts from outside the build
# directory, in order to ensure everything flows through the
# checkout->build->upload pipeline. So disallow uploading files from
# outside the build directory.
#
# Links to files outside the build directory are still allowed.
if ".." in f.source.split("/"):
raise api.step.StepFailure(
"CIPD upload file source must within the build directory: %s" % f.source
)
abs_source = build_dir.join(*f.source.split("/"))
# For convenience, projects can specify dest="." to upload an entire
# directory as the contents of the package.
if f.dest == ".":
if len(cipd_manifest.files) > 1: # pragma: no cover
raise api.step.StepFailure(
"Only one CIPD manifest entry is allowed if any entry's destination is '.'"
)
# No need for a tree of symlinks anymore, we can just treat the
# source directory as the staging directory.
staging_dir = abs_source
tree = None
break
abs_dest = tree.root.join(*f.dest.split("/"))
tree.register_link(abs_source, linkname=abs_dest)
if tree:
tree.create_links("create hardlinks")
api.cipd_util.upload_package(
cipd_package,
staging_dir,
search_tag={"git_revision": git_revision},
repository=repository,
)
def GenTests(api):
default_remote = "https://fuchsia.googlesource.com/foo"
def properties(**kwargs):
props = {
"remote": default_remote,
"build_command": {
"path": "scripts/build.sh",
"args": [
"--build-dir",
"{build_dir}",
"--version",
"{placeholder_version}",
],
},
}
props.update(kwargs)
return api.properties(jsonpb.ParseDict(props, InputProperties()))
def cipd_manifest_data(files=None):
if not files:
files = {"local/foo/bar": "package/foobar"}
return api.proto.output(
CIPDUploadManifest(
files=[
CIPDUploadManifest.FileToUpload(source=k, dest=v)
for k, v in files.items()
]
)
)
yield (
api.buildbucket_util.test("fxt_tests", git_repo=default_remote)
+ properties(
gcs_manifest_command={"path": "emit_gcs_manifest.sh"},
gcs_bucket="foo-artifacts",
sign_artifacts=True,
fxt_options={"image_name": "image-name"},
)
+ api.step_data(
"gcs upload.generate gcs manifest",
stdout=api.json.output([{"source": "foo.txt", "destination": "foo.txt"}]),
)
+ api.fxt.orchestrate_fxt_tests()
)
yield (
api.buildbucket_util.test("jiri__mos__cipd", git_repo=default_remote)
+ properties(
jiri_manifest="path/to/manifest",
download_command={"path": "scripts/download_deps.sh"},
mos_upload_options={
"repo_hostname": "test.fuchsia-update.googleusercontent.com",
"gcs_bucket": "discover-cloud.appspot.com",
"manifest_path": "path/to/mos/manifest",
},
cipd_manifest_commands=[
{
"command": {"path": "scripts/emit_foo_cipd_manifest.sh"},
"package": "fuchsia/tools/foo",
}
],
)
+ api.step_data(
"cipd upload fuchsia/tools/foo.generate cipd manifest",
stdout=cipd_manifest_data(
# Cover the case where we upload an entire subdirectory into the
# root of the CIPD package.
{"dir/pkg": "."},
),
)
)
yield (
api.buildbucket_util.test(
"invalid_cipd_upload",
git_repo=default_remote,
# The manifest should be validated even in presubmit.
tryjob=True,
status="failure",
)
+ properties(
cipd_manifest_commands=[
{
"command": {"path": "scripts/emit_cipd_manifest.sh"},
"package": "fuchsia/tools/foo",
}
],
)
+ api.step_data(
"cipd upload fuchsia/tools/foo.generate cipd manifest",
stdout=cipd_manifest_data(
# Paths outside the build directory are disallowed.
{"foo/../../checkout-path": "package_path/foo/bar"}
),
)
)
yield (
api.buildbucket_util.test(
"cipd_upload_tryjob", git_repo=default_remote, tryjob=True
)
+ properties(
cipd_manifest_commands=[
{
"command": {"path": "scripts/emit_cipd_manifest.sh"},
"package": "fuchsia/tools/foo",
}
],
)
+ api.step_data(
"cipd upload fuchsia/tools/foo.generate cipd manifest",
stdout=cipd_manifest_data(),
)
)
yield (
api.buildbucket_util.test(
"failed_tests", git_repo=default_remote, status="failure"
)
+ properties(
testing_pool="testpool",
test_manifest_command={"path": "scripts/emit_test_manifest.sh"},
)
+ api.step_data(
"generate test manifest",
stdout=api.proto.output(
TestManifest(
root_dir="[START_DIR]/foo",
test_groups=[
TestManifest.TestGroup(
name="group1",
command=["run-tests", "--verbose"],
exec_dir="path/to/exec/dir",
scheduling_dimensions=[("foo", "bar")],
# The first file should be ignored since its entire
# directory is included.
runfiles=["some/runtime/dep", "some/runtime"],
)
],
)
),
)
+ api.swarming_retry.collect_data(
[api.swarming_retry.failed_task(name="group1", task_id=0)]
)
)