blob: d986f725ddc48ac37648ea443229ed39c9f96231 [file] [log] [blame] [edit]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2025 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.
"""Describe or export Bazel host test artifacts to the Ninja build directory."""
import argparse
import dataclasses
import json
import os
import shutil
import sys
import typing as T
from pathlib import Path
_SCRIPT_DIR = os.path.dirname(__file__)
_SCRIPT_DIR_PATH = Path(_SCRIPT_DIR)
sys.path.insert(0, _SCRIPT_DIR)
import build_utils
import runfiles_utils
# The main workspace name as it appears in the runfiles directory.
# For the Fuchsia build, which is still using Bazel 7.x and WORKSPACE
# files, this is "main", but will become `_main` when enabling BzlMod
_MAIN_WORKSPACE_NAME = "main"
_LIST_TESTS_COMMAND_HELP = r"""
Generate a Fuchsia-compatible `tests.json` file listing host test targets
reachable from one ore more Bazel target pattern(s). This can be called
directly after `fx set` or `fx gen`, as it does not require building anything.
The only difference from the GN-generated `tests.json` file is that each
entry's target label begins with an `@`, and does not include any toolchain
suffix. For example, the following command:
```bash
python3 build/bazel/scripts/export_host_test.py \
list_tests \
//build/bazel/host_tests/static_cc_test/...
```
Produces the following output:
```json
[
{
"environments": [
{
"dimensions": {
"cpu": "x64",
"os": "Linux"
}
}
],
"expects_ssh": false,
"label": "@//build/bazel/host_tests/static_cc_test:test_program",
"name": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program",
"path": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program",
"runtime_deps": "bazel_host_tests/build/bazel/host_tests/static_cc_test/test_program.runtime_deps.json",
"os": "linux",
"cpu": "x64"
}
]
```
All exported test artifacts are located under $NINJA_BUILD_DIR/bazel_host_tests/ by default. This
location can be changed with the --export-subdir=SUBDIR argument. The rest of the path must follow
the Bazel-specific execroot-relative file layout.
Use the `export` command to create the test artifacts and the `.runtime_deps.json` file.
"""
_EXPORT_COMMAND_HELP = r"""Export Bazel test artifacts to the Ninja build directory. Making them
usable outside of the Bazel output base.
For example, the following command:
```bash
python3 build/bazel/scripts/export_host_test.py \
export \
//build/bazel/host_tests/static_cc_test/...
```
Will populate the build directory with files that look like:
```
bazel_host_tests/build/bazel/host_tests/
static_cc_test/
test_program
test_program.runtime_deps.json
test_program.repo_mapping
test_program.runfiles_manifest
test_program.runfiles/
...
```
Where `test_program` is the test entry point that is listed in the
corresponding entry generated by the `list` command.
NOTE: In many cases, the test executable (e.g. `test_program` here) *MUST* be invoked from
the Ninja build directory to work correctly.
"""
# Location of the file containing the Starlark cquery code to run in this script
# to locate all test targets.
_CQUERY_STARLARK_FILE_PATH = (
_SCRIPT_DIR_PATH.parent / "starlark" / "host_test_info.cquery"
)
# The sub-directory, relative to the Ninja build directory, where all Bazel host test
# artifacts will be exported by this script. The content will be mostly symlinks into
# the Bazel output base, unless the --no-symlinks option is used.
_DEFAULT_EXPORT_SUBDIR = "bazel_host_tests"
class HostInfo(object):
"""Convenience class to expose host-specific information."""
def __init__(self) -> None:
self._os = build_utils.get_host_platform()
self._cpu = build_utils.get_host_arch()
@property
def os(self) -> str:
"""The host operating system name, following Fuchsia conventions."""
return self._os
@property
def cpu(self) -> str:
"""The host cpu architecture name, following Fuchsia conventions."""
return self._cpu
@property
def test_json_environments(self) -> list[dict[str, T.Any]]:
"""The value of a tests.json "environments" key for a given host test."""
# See https://fuchsia.dev/fuchsia-src/reference/testing/tests-json-format?hl=en#environments_field
# and //build/testing/environments.gni.
# There are only two host environment supported, Linux and MacOS
# using environments[].dimensions.os values "Linux" and "Mac"
# respectively.
host_os = {"linux": "Linux", "mac": "Mac"}.get(self._os)
assert host_os, f"Unsupported host operating system: {self._os}"
return [
{
"dimensions": {
"cpu": self._cpu,
"os": host_os,
}
}
]
def hardlink_or_copy_file(src_path: Path, dst_path: Path) -> None:
"""Hardlink or copy a source file to a destination path.
If the source path is a directory, ValueError is raised.
If the source path is a symlink, a symlink is created at the destination
that points to the same location, but using a destination-relative target path.
If the hardlink fails, a copy operation will be performed.
Args:
src_path: Source file path.
dst_path: Destination path.
Raise:
ValueError if src_path is a directory path.
"""
# Resolve symlink source paths.
if src_path.is_dir():
raise ValueError(f"Cannot hard-link or copy directory: {src_path}")
dst_path.parent.mkdir(parents=True, exist_ok=True)
if dst_path.is_symlink():
dst_path.unlink()
if src_path.is_symlink():
dst_path.symlink_to(
os.path.relpath(src_path.resolve(), dst_path.parent)
)
else:
try:
dst_path.hardlink_to(src_path)
except Exception:
shutil.copyfile(src_path, dst_path)
def filter_bazel_bin_path(bazel_path: str) -> str:
"""Filter the bazel-out/<config-dir>/bin/ prefix of an execroot-relative path.
Args:
bazel_path: A path relative to the Bazel execroot, which for artifacts
must begin with `bazel-out/<config_dir>/bin/`.
Returns:
The same path without the `bazel-out/<config_dir>/bin/` prefix.
Raises:
ValueError if the path is not formed properly.
"""
segments = bazel_path.split("/")
if len(segments) <= 3 or segments[0] != "bazel-out" or segments[2] != "bin":
raise ValueError(
f"Expected execroot-relative artifact path, got [{bazel_path}]"
)
return "/".join(segments[3:])
class HostTestInfo(object):
"""Record information about a given host test, and produce outputs accordingly."""
def __init__(
self,
test_input: dict[str, T.Any],
host_info: HostInfo,
export_subdir: str = "",
) -> None:
"""Create new instance.
Args:
test_input: A dictionary modeling the host_test_info.cquery output
for the Bazel test target.
host_info: A HostInfo instance describing the current host system.
export_subdir: An optional sub-directory path, relative to the export directory.
The default is an empty string, meaning no sub-directory will be used.
Raises:
AssertionError is test_input is malformed.
"""
self._host_info = host_info
self._export_prefix = f"{export_subdir}/" if export_subdir else ""
# LINT.IfChange
self._input = test_input
self._label = self._input["label"]
assert self._label.startswith(
"@"
), f"Invalid Bazel target label does not begin with @: {self._label}"
# The path to the tests's entry point, relative to the Ninja build directory
# (or the top of the test archive). Because the test archive does not support
# symlinks, this will be a shell script that invokes the real test binary
# with an "exec" call.
self._entry_path = self._export_prefix + filter_bazel_bin_path(
self._input["executable"]
)
# The path to the file describing the runtime dependencies for the host test.
# Its content can only be generated after building the test, as its depends on
# the runfiles manifest's content, which is not available through Bazel cqueries.
self._runtime_deps_path = f"{self._entry_path}.runtime_deps.json"
# LINT.ThenChange(../starlark/host_test_info.cquery)
def generate_tests_json_entry(self) -> dict[str, T.Any]:
"""Generate a single tests.json entry describing the current host test.
Returns:
A new JSON dictionary describing the host test, following the schema at
https://fuchsia.dev/fuchsia-src/reference/testing/tests-json-format
"""
# For now, use the test path as the name. `fx test` will use the basename
# to find it.
name = self._entry_path
return {
"environments": self._host_info.test_json_environments,
"expects_ssh": False,
"label": self._label,
"name": name,
"path": self._entry_path,
"runtime_deps": self._runtime_deps_path,
"os": self._host_info.os,
"cpu": self._host_info.cpu,
}
def export_to(self, build_dir: Path, execroot: Path) -> None:
"""Export this host test to a Ninja {build_dir}.
Mirror the executable and its runfiles support files into the
Ninja build directory into {build_dir}/{export_subdir}/
For example, given the following Bazel test files:
```
{EXECROOT}/bazel-out/k8-fastbuild/bin/
src/
foo
foo.repo_manifest
foo.runfiles_manifest
foo.runfiles/
_main/
src/
foo ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo
data/
file ---> {WORKSPACE}/data/file
_repo_mapping ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.repo_mapping
MANIFEST ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.runfiles_manifest
```
This will create the following in the Ninja {build_dir}:
```
{export_subdir}/
src/
foo --> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo
foo.repo_manifest --> {EXECROOT}/bazel-out/k8-fastbuild/
foo.runfiles_manifest # ADJUSTED MANIFEST (see below)
foo.runtime_deps.json
foo.runfiles/
_main/
src/
foo ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo
data/
file ---> {WORKSPACE}/data/file
_repo_mapping ---> {EXECROOT}/bazel-out/k8-fastbuild/bin/src/foo.repo_mapping
MANIFEST ---> {build_dir}/{export_subdir}/src/foo.runfiles_manifest
```
The major differences are:
- The `{export_subdir}/src/foo.runtime_deps.json` file, which will be referenced from
`tests.json`, which contains a JSON list of paths to all the runtime-required files,
relative to the Ninja {build_dir}. In this case, this will look like:
```
{export_subdir}/src/foo
{export_subdir}/src/foo.repo_mapping
{export_subdir}/src/foo.runfiles_manifest
{export_subdir}/src/foo.runfiles/_main/src/foo
{export_subdir}/src/foo.runfiles/_main/data/file
{export_subdir}/src/foo.runfiles/MANIFEST
```
- The `{export_subdir}/src/foo.runtime_manifest` is adjusted to list target paths that
are also relative to the Ninja {build_dir}, so will contain something like:
```
_main/src/foo {export_subdir}/src/foo.runfiles/_main/src/foo
_main/data/file {export_subdir}/src/foo.runfiles/_main/data/file
_repo_mapping {export_subdir}/src/foo.runfiles/_repo_mapping
```
IMPORTANT: Running the test requires launching it from the Ninja {build_dir}, though
there are exceptions for py_test() and sh_test() targets, because they use
a middle-man script that can probe the environment and file-system to adjust
paths accordingly.
Args:
build_dir: Root export directory, a.k.a. Ninja build directory.
execroot: Bazel execroot absolute path. Used to resolve relative paths
in runfiles manifest.
"""
# First, locate the input runfiles manifest, load it, and clean it up a little.
input_manifest_path = execroot / self._input["runfiles_manifest"]
assert (
input_manifest_path.exists()
), f"Missing Bazel runfiles manifest: {input_manifest_path}"
input_manifest = runfiles_utils.RunfilesManifest.CreateFrom(
input_manifest_path.read_text()
)
input_manifest.remove_legacy_external_runfiles(
workspace_name=_MAIN_WORKSPACE_NAME
)
input_runfiles_dir = execroot / f"{self._input['executable']}.runfiles"
assert (
input_runfiles_dir.exists()
), f"Missing Bazel runfiles directory: {input_runfiles_dir}"
# Second, locate the _repo_mapping file from it. This should be an absolute path or
# an execroot-relative one.
repo_mapping_path = Path(input_manifest.lookup("_repo_mapping"))
assert (
repo_mapping_path
), f"Missing _repo_mapping entry from runfiles manifest at: {input_manifest_path}"
if not repo_mapping_path.is_absolute():
repo_mapping_path = execroot / repo_mapping_path
assert (
repo_mapping_path.exists()
), f"Missing Bazel repository mapping file: {repo_mapping_path}"
# For every entry in the manifest, create a corresponding symlink in the output runfiles directory.
output_runfiles_dir = build_dir / f"{self._entry_path}.runfiles"
if output_runfiles_dir.exists():
shutil.rmtree(output_runfiles_dir)
output_runfiles_dir.mkdir(parents=True)
runtime_deps = []
output_manifest_entries: dict[str, str] = {}
for source_path, target_path in input_manifest.as_dict().items():
dest_path = output_runfiles_dir / source_path
runtime_path = os.path.relpath(dest_path, build_dir)
if not target_path:
# This is an empty file in the input runfiles dir, create an empty
# one in the output runfiles dir too.
dest_path.parent.mkdir(parents=True, exist_ok=True)
dest_path.write_text("")
else:
if not os.path.isabs(target_path):
target_path = f"{execroot}/{target_path}"
build_utils.force_symlink(dest_path, target_path)
runtime_deps.append(runtime_path)
output_manifest_entries[source_path] = runtime_path
# Create the MANIFEST file in the destination runfiles directory.
exported_manifest = runfiles_utils.RunfilesManifest(
output_manifest_entries
)
output_manifest_path = output_runfiles_dir / "MANIFEST"
output_manifest_path.write_text(exported_manifest.generate_content())
runtime_deps.append(os.path.relpath(output_manifest_path, build_dir))
# Add foo.runfiles_manifest and foo.repo_mapping symlinks as well.
output_manifest_link_path = (
build_dir / f"{self._entry_path}.runfiles_manifest"
)
build_utils.force_symlink(
output_manifest_link_path, output_manifest_path
)
runtime_deps.append(
os.path.relpath(output_manifest_link_path, build_dir)
)
output_repo_mapping_link_path = (
build_dir / f"{self._entry_path}.repo_mapping"
)
build_utils.force_symlink(
output_repo_mapping_link_path, output_runfiles_dir / "_repo_mapping"
)
runtime_deps.append(
os.path.relpath(output_repo_mapping_link_path, build_dir)
)
# Write the foo.runtime_deps.json file.
output_runtime_deps_path = build_dir / self._runtime_deps_path
output_runtime_deps_path.write_text(json.dumps(runtime_deps, indent=2))
# Finally, hardlink or copy the executable itself. A symlink will not work for
# Python and shell tests because the executable is a middleman script that may
# use expressions like |`readlink $0`.runfiles| to find the runfiles directory
# and this will not work.
hardlink_or_copy_file(
execroot / self._input["executable"],
build_dir / self._entry_path,
)
@dataclasses.dataclass
class CommandContext(object):
"""Common context for all script commands."""
args: argparse.Namespace
build_dir: Path
tests_map: dict[str, HostTestInfo]
bazel_cmd: build_utils.BazelCommand
def list_command(args: argparse.Namespace, context: CommandContext) -> int:
"""Implement the `list` script command."""
# Create a tests.json file corresponding to the test_info entries.
tests_json = []
for label, test_info in sorted(context.tests_map.items()):
tests_json.append(test_info.generate_tests_json_entry())
content = json.dumps(tests_json, indent=2)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(content)
else:
print(content)
return 0
def export_command(args: argparse.Namespace, context: CommandContext) -> int:
"""Implement the `export` command."""
execroot = context.bazel_cmd.get_execroot()
# Build them, as we require the runfiles manifest file to parse it.
context.bazel_cmd.run("build", sorted(args.target_pattern))
build_dir = context.build_dir
if args.output_dir:
build_dir = args.output_dir
for label, test_info in context.tests_map.items():
test_info.export_to(build_dir, execroot)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--fuchsia-dir",
type=Path,
help="Path to Fuchsia checkout (auto-detected).",
)
parser.add_argument(
"--build-dir",
type=Path,
help="Path to Fuchsia build directory (auto-detected).",
)
parser.add_argument(
"--export-subdir",
type=Path,
default=_DEFAULT_EXPORT_SUBDIR,
help=f"Set build_dir sub-directory for export (default {_DEFAULT_EXPORT_SUBDIR}).",
)
subparsers = parser.add_subparsers(required=True)
parser_list = subparsers.add_parser(
"list",
help="Generate a tests.json file from one or more Bazel target patterns.",
formatter_class=argparse.RawTextHelpFormatter,
description=_LIST_TESTS_COMMAND_HELP,
)
parser_list.add_argument(
"--output",
type=Path,
help="Write result to file, default goes to stdout.",
)
parser_list.add_argument(
"target_pattern", nargs="+", help="Bazel host test label patterns"
)
parser_list.set_defaults(func=list_command)
parser_export = subparsers.add_parser(
"export",
help="Export Bazel test artifacts to the Ninja build directory.",
description=_EXPORT_COMMAND_HELP,
formatter_class=argparse.RawTextHelpFormatter,
)
parser_export.add_argument(
"--output-dir",
type=Path,
help="Specify alternative output directory, default is Ninja build directory.",
)
parser_export.add_argument(
"target_pattern", nargs="+", help="Bazel host test label patterns"
)
parser_export.set_defaults(func=export_command)
args = parser.parse_args()
bazel_paths = build_utils.BazelPaths.new(args.fuchsia_dir, args.build_dir)
bazel_launcher = bazel_paths.launcher
if not bazel_launcher.exists():
parser.error(
f"Could not find Bazel launcher script! fuchsia_dir={fuchsia_dir} build_dir={build_dir}"
)
bazel_cmd = build_utils.BazelCommand(bazel_launcher)
# Now query each target to get its executable and runfiles_manifest path
# in the output base, then load the runfiles manifest.
patterns = " ".join(args.target_pattern)
output = bazel_cmd.run(
"cquery",
[
"--output=starlark",
f"--starlark:file={_CQUERY_STARLARK_FILE_PATH.resolve()}",
f"tests(set({patterns}))",
],
)
host_info = HostInfo()
tests_map = {}
for line in output.splitlines():
test_entry = json.loads(line)
test_info = HostTestInfo(test_entry, host_info, args.export_subdir)
tests_map[test_entry["label"]] = test_info
context = CommandContext(
args, bazel_paths.ninja_build_dir, tests_map, bazel_cmd
)
return args.func(args, context)
if __name__ == "__main__":
sys.exit(main())