blob: 6a9307ffae14c308d1937601bbf1aaf8b06490d1 [file]
# Copyright 2025 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Run './pw' tools and builds."""
from __future__ import annotations
import contextlib
import re
import shlex
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
from recipe_engine import (
config_types,
post_process,
recipe_api,
recipe_test_api,
)
from PB.pigweed.pw_build.proto import build_driver as build_driver_pb
from PB.pigweed.pw_build.proto import workflows as workflows_pb
from PB.recipe_engine import result as result_pb
from PB.recipes.pigweed.workflows import InputProperties
from RECIPE_MODULES.pigweed.checkout import api as checkout_api
from RECIPE_MODULES.pigweed.gcs_upload import api as gcs_upload_api
from RECIPE_MODULES.pigweed.raw_result import api as raw_result_api
DEPS = [
'fuchsia/buildbucket_util',
'fuchsia/macos_sdk',
'fuchsia/sso',
'pigweed/bazel',
'pigweed/checkout',
'pigweed/default_timeout',
'pigweed/gcs_upload',
'pigweed/initialization',
'pigweed/raw_result',
'pigweed/trigger',
'recipe_engine/buildbucket',
'recipe_engine/cipd',
'recipe_engine/context',
'recipe_engine/defer',
'recipe_engine/file',
'recipe_engine/path',
'recipe_engine/platform',
'recipe_engine/properties',
'recipe_engine/proto',
'recipe_engine/raw_io',
'recipe_engine/step',
]
PROPERTIES = InputProperties
def _test_request() -> build_driver_pb.BuildDriverRequest:
result = build_driver_pb.BuildDriverRequest()
build = result.jobs.add().build
build.name = 'test_build'
build.description = 'test_build description'
build.build_config.name = 'test_build config'
build.build_config.description = 'test_build config description'
build.build_config.build_type = 'test_build config build_type'
build.build_config.args.extend(('--arg-1', '--arg-2'))
build.rerun_shortcut = 'build test_build'
tool = result.jobs.add().tool
tool.name = 'test_tool'
tool.description = 'test_tool description'
tool.build_config.name = 'test_tool config'
tool.build_config.description = 'test_tool config description'
tool.build_config.build_type = 'test_tool config build_type'
tool.build_config.args.extend(('--arg-a', '--arg-b'))
tool.analyzer_friendly_args.append('--check')
tool.rerun_shortcut = 'check test_tool'
return result
@raw_result_api.wrap_run_steps
def RunSteps(
api: recipe_api.RecipeScriptApi, props: InputProperties
) -> result_pb.RawResult | None:
exit_early, checkout = api.initialization(props.checkout_options)
if exit_early:
return exit_early # pragma: no cover
assert checkout
# b/445709816 - Old Bazel servers can persist on macOS bots across
# different runs, which causes remote cache authentication/connection
# problems.
if api.platform.is_mac:
build = api.buildbucket.build
if 'pigweed.skip_mac_bazel_shutdown' not in build.input.experiments:
with api.step.nest('shut down old Bazel server'):
bazel = api.bazel.ensure_bazelisk()
with api.context(cwd=checkout.root):
api.step('shut down old Bazel server', [bazel, 'shutdown'])
with contextlib.ExitStack() as stack:
stack.enter_context(api.step.nest('describe steps'))
stack.enter_context(_bazel_cache_context(api))
experiments = api.buildbucket.build.input.experiments
assert not (
props.bazel_version and 'pigweed.latest_bazel' in experiments
)
if props.bazel_version:
stack.enter_context(
api.context(env={'USE_BAZEL_VERSION': props.bazel_version}),
)
if 'pigweed.latest_bazel' in experiments:
stack.enter_context(
api.context(env={'USE_BAZEL_VERSION': 'latest'}),
)
bazel = api.bazel.ensure_bazelisk()
workflow_output_base = api.path.cleanup_dir / 'workflows_launcher'
extra_bazel_arguments = _process_extra_bazel_arguments(
props.extra_bazel_arguments,
{'CHECKOUT_ROOT': checkout.root},
)
bazel_args = [
*api.bazel.rbe_arguments(remote=False, remote_cache=True),
*api.bazel.override_arguments(checkout),
*api.bazel.airlock_arguments(),
*extra_bazel_arguments,
]
wrapped_bazel_args = [f'--extra-arg=bazel={x}' for x in bazel_args]
build_args = [
bazel,
f'--output_base={workflow_output_base}',
'build',
f'--symlink_prefix={workflow_output_base}/bazel-',
*bazel_args,
# Don't upload BES results whenever we launch the workflows entry
# point. This prevents noisy BES results urls at the top of each
# build page that are unrelated to the launched steps.
'--bes_results_url=',
'//:pw',
]
cmd_prefix = [
*['run' if arg == 'build' else arg for arg in build_args],
'--',
*wrapped_bazel_args,
]
describe_cmd = [
*cmd_prefix,
'--directory',
checkout.root,
'--no-banner',
'describe',
'--infra-metadata',
*props.steps,
]
# TODO: b/441786225 - Remove bazel.parent from PATH.
with api.context(
cwd=checkout.root,
env_prefixes={'PATH': [bazel.parent]},
):
with api.macos_sdk():
api.step('build pw', build_args)
steps_raw = api.step(
'describe steps',
describe_cmd,
stdout=api.raw_io.output_text(),
step_test_data=(
lambda: api.raw_io.test_api.stream_output_text(
api.proto.encode(_test_request(), api.proto.TEXTPB),
)
),
).stdout
with api.step.nest('steps') as pres:
pres.logs['steps'] = steps_raw
steps_proto = api.proto.decode(
steps_raw,
build_driver_pb.BuildDriverRequest,
api.proto.TEXTPB,
)
for step in steps_proto.jobs:
if step.HasField('build'):
api.step.empty(step.build.rerun_shortcut)
elif step.HasField('tool'):
api.step.empty(step.tool.rerun_shortcut)
else:
raise ValueError('empty oneof') # pragma: no cover
keep_going_base = ()
for change in checkout.changes:
if 'build-errors: continue' in change.commit_message.lower():
keep_going_base = ('--keep_going',)
gcs_raw_result: result_pb.RawResult | None = None
with contextlib.ExitStack() as outer_stack:
defer = outer_stack.enter_context(api.defer.context())
gcs: gcs_upload_api.GcsUploadContext | None = None
if props.gcs_bucket:
gcs = outer_stack.enter_context(
api.gcs_upload(gcs_bucket=props.gcs_bucket),
)
with api.step.nest('upload metadata'):
checkout_snapshot = api.path.mkdtemp() / '_checkout'
checkout.snapshot_to_dir(checkout_snapshot)
gcs.upload(checkout_snapshot, '_checkout')
outer_stack.enter_context(_bazel_cache_context(api))
for job in steps_proto.jobs:
if job.HasField('build'):
rerun_shortcut = job.build.rerun_shortcut
keep_going = keep_going_base
elif job.HasField('tool'):
rerun_shortcut = job.tool.rerun_shortcut
keep_going = ()
else:
raise ValueError('empty oneof') # pragma: no cover
with contextlib.ExitStack() as stack:
# TODO: b/441786225 - Remove bazel.parent from PATH.
stack.enter_context(
api.context(
cwd=checkout.root,
env={'LUCI_BUILD_ID': str(api.buildbucket.build.id)},
env_prefixes={'PATH': [bazel.parent]},
),
)
pres = stack.enter_context(api.step.nest(rerun_shortcut))
stack.enter_context(api.default_timeout())
stack.enter_context(api.macos_sdk())
artifacts_manifest: str | None = None
artifacts_manifest_args: tuple[str, ...] = ()
if gcs or props.cipd_yaml_file:
artifacts_manifest = (
api.path.mkdtemp() / 'artifacts-manifest.textproto'
)
artifacts_manifest_args = (
f'--artifacts-manifest={artifacts_manifest}',
)
step_cmd = [
*cmd_prefix,
*artifacts_manifest_args,
*shlex.split(rerun_shortcut),
*keep_going,
]
defer(api.step, 'run', step_cmd)
if gcs or props.cipd_yaml_file:
artifacts = read_artifacts(api, artifacts_manifest)
if gcs:
num_uploaded = upload_artifacts_to_gcs(
api,
gcs,
artifacts,
)
if num_uploaded:
entries = 'entries' if num_uploaded > 1 else 'entry'
pres.step_summary_text = (
f'uploaded {num_uploaded} {entries}'
)
if props.cipd_yaml_file:
if not api.buildbucket_util.is_dev_or_try:
pin = upload_artifacts_to_cipd(
api,
checkout=checkout,
artifacts=artifacts,
yaml_file=props.cipd_yaml_file,
refs=props.cipd_refs,
)
pres.links['@'.join(pin)] = api.cipd.make_link(*pin)
if gcs:
gcs_raw_result = gcs.raw_result()
api.trigger(props.trigger_options, checkout, verbose=True)
return gcs_raw_result
def _process_extra_bazel_arguments(
extra_bazel_arguments: Sequence[str],
variables: Mapping[str, str],
):
result: list[str] = []
for arg in extra_bazel_arguments:
def repl(match: re.Match) -> str:
return str(variables.get(match.group(1), match.group(0)))
final_arg = arg
final_arg = re.sub(r'\$([\w]+)', repl, final_arg)
final_arg = re.sub(r'\$\{([\w]+)\}', repl, final_arg)
result.append(final_arg)
return result
def _bazel_cache_context(api: recipe_api.RecipeScriptApi):
# See b/441999854. Don't allow builds that have github/RBE disabled and
# builds that don't to share Bazel caches. For now, do this by disabling
# the local cache for builds that disable github.
test_tmpdir = api.path.cache_dir / 'bazel'
if (
'pigweed.disable_github' in api.buildbucket.build.input.experiments
or 'pigweed.disable_rbe' in api.buildbucket.build.input.experiments
):
test_tmpdir = api.path.mkdtemp()
return api.context(env={'TEST_TMPDIR': test_tmpdir})
def _test_artifacts(
api: recipe_api.RecipeScriptApi,
) -> workflows_pb.BuildArtifacts:
result = workflows_pb.BuildArtifacts()
output_root = api.path.start_dir / 'output'
result.output_root = str(output_root)
group_one = result.output_groups.add()
group_one.add_prefix = 'one/'
group_one.matching_files.append('foo/bar.txt')
group_two = result.output_groups.add()
group_two.strip_prefix = 'prefix/'
group_two.matching_files.append('prefix/ham.txt')
group_two.matching_files.append('prefix/spam.txt')
group_two.matching_files.append('prefix/eggs.txt')
for group in result.output_groups:
for file in group.matching_files:
api.path.mock_add_file(output_root / file)
return result
def read_artifacts(
api: recipe_api.RecipeScriptApi,
manifest: config_types.Path,
) -> workflows_pb.BuildArtifacts:
api.path.mock_add_file(manifest)
assert api.path.isfile(manifest), manifest
raw_artifacts = api.file.read_text(
'read artifacts manifest',
manifest,
test_data=api.proto.encode(_test_artifacts(api), api.proto.TEXTPB),
)
return api.proto.decode(
raw_artifacts,
workflows_pb.BuildArtifacts,
api.proto.TEXTPB,
)
def upload_artifacts_to_gcs(
api: recipe_api.RecipeScriptApi,
gcs: gcs_upload_api.GcsUploadContext,
artifacts: workflows_pb.BuildArtifacts,
) -> int:
num_uploaded = 0
output_root = api.context.cwd / artifacts.output_root
for group in artifacts.output_groups:
for file in group.matching_files:
source = output_root / file
api.path.mock_add_file(source)
destination = file.removeprefix(group.strip_prefix or '').lstrip(
'/'
)
if group.add_prefix:
prefix = group.add_prefix.strip('/')
destination = f'{prefix}/{destination}'
gcs.upload(source, destination)
num_uploaded += 1
return num_uploaded
def upload_artifacts_to_cipd(
api: recipe_api.RecipeScriptApi,
checkout: checkout_api.CheckoutContext,
artifacts: workflows_pb.BuildArtifacts,
yaml_file: config_types.Path,
refs: Sequence[str],
) -> None:
yaml_path = api.context.cwd / artifacts.output_root / yaml_file
api.path.mock_add_file(yaml_path)
assert api.path.isfile(yaml_path), yaml_path
metadata = [
api.cipd.Metadata(
'git_repository',
api.sso.sso_to_https(checkout.options.remote),
),
]
tags = {
'git_revision': checkout.revision(),
}
return api.cipd.create_from_yaml(
pkg_def=yaml_path,
refs=refs,
tags=tags,
metadata=metadata,
)
def GenTests(
api: recipe_test_api.RecipeTestApi,
) -> Iterator[recipe_test_api.TestData]:
def properties(**kwargs: Any) -> recipe_test_api.TestData:
props = InputProperties(**kwargs)
props.checkout_options.CopyFrom(api.checkout.git_options())
return api.properties(props)
def must_upload(
prefix: str,
name: str,
bucket: str = 'bucket',
) -> recipe_test_api.TestData:
return api.post_process(
post_process.MustRun,
f'{prefix}.upload {name}.upload {name} to {bucket}',
)
def has_args(step, *args):
return api.post_process(post_process.StepCommandContains, step, args)
create_step = 'check test_tool.create cipd.yaml'
yield api.test(
'upload',
properties(
gcs_bucket='bucket',
cipd_yaml_file='cipd.yaml',
cipd_refs=('latest',),
),
must_upload('build test_build', 'one/foo/bar.txt'),
must_upload('build test_build', 'ham.txt'),
must_upload('build test_build', 'spam.txt'),
must_upload('build test_build', 'eggs.txt'),
has_args(create_step, '-ref', 'latest'),
has_args(create_step, '-tag', 'git_revision:HASH'),
has_args(create_step, '-metadata', re.compile('^git_repository:.*')),
api.post_process(post_process.DropExpectation),
)
yield api.test(
'mac',
properties(
extra_bazel_arguments=[
'--extra-arg=${CHECKOUT_ROOT}/with',
'--extra-arg=$CHECKOUT_ROOT/without',
],
),
api.buildbucket.try_build(
experiments=['pigweed.disable_rbe', 'pigweed.latest_bazel'],
git_repo='https://pigweed.googlesource.com/pigweed/pigweed',
),
api.platform.name('mac'),
api.post_check( # One check that './pw' is invoked with the arg.
post_process.StepCommandContains,
'build test_build.run',
[
'--extra-arg=[START_DIR]/co/with',
'--extra-arg=[START_DIR]/co/without',
],
),
api.post_check( # One check that the build is invoked with the arg.
post_process.StepCommandContains,
'build test_build.run',
[
'--extra-arg=bazel=--extra-arg=[START_DIR]/co/with',
'--extra-arg=bazel=--extra-arg=[START_DIR]/co/without',
],
),
api.post_process(post_process.DropExpectation),
)
yield api.test(
'build-errors-continue',
properties(bazel_version='1.2.3.4.5.6.7.8.9.0'),
api.checkout.try_test_data(),
api.checkout.cl_branch_parents(message='Build-Errors: continue'),
api.post_check(
post_process.StepCommandContains,
'build test_build.run',
['--keep_going'],
),
api.post_check(
post_process.StepCommandDoesNotContain,
'check test_tool.run',
['--keep_going'],
),
api.post_process(post_process.DropExpectation),
)