blob: 1052f957e6fbe345bd78a60f697df5f87b2d3d3e [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2024 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.
"""Regenerate the Ninja build plan and the Bazel workspace for the Fuchsia build system."""
import argparse
import filecmp
import json
import os
import shlex
import shutil
import subprocess
import sys
import typing as T
from pathlib import Path
import build_tests_json
_SCRIPT_DIR = Path(__file__).parent
# The directory that contains helper python modules for this script.
_BUILD_BAZEL_SCRIPTS = _SCRIPT_DIR / ".." / "build" / "bazel" / "scripts"
sys.path.insert(0, str(_BUILD_BAZEL_SCRIPTS))
import compute_content_hash
import remote_services_utils
import workspace_utils
_DEFAULT_HOST_TAG = "linux-x64"
def interpret_gn_path(gn_path: str, fuchsia_dir: Path, build_dir: Path) -> Path:
"""Convert a GN path value into the right Path value.
Args:
gn_path: The GN path string.
fuchsia_dir: The Path value of the Fuchsia source directory.
build_dir: The Path value of the Fuchsia build directory.
Returns:
A Path value.
"""
if gn_path.startswith("//"):
return fuchsia_dir / gn_path[2:] # source-relative
if gn_path.startswith("/"):
return Path(gn_path) # absolute path
return build_dir / gn_path # build-dir relative path.
def generate_bazel_content_hash_files(
fuchsia_dir: Path,
build_dir: Path,
output_dir: Path,
bazel_content_hashes: T.Any,
) -> T.Set[Path]:
"""Generate the content hash files required by the Bazel workspace.
For each entry of the input list, write {output_dir}/{repo_name}.hash
Args:
fuchsia_dir: Path to Fuchsia source directory.
build_dir: Path to the Fuchsia build directory.
bazel_workspace: Path to the Bazel workspace.
definitions_json: The JSON value of the `$BUILD_DIR/bazel_content_hashes.json` file
generated at `gn gen` time (see //build/bazel:bazel_content_hashes_json).
Returns:
A set of input Path values to be added to the list of extra inputs for the Ninja build
plan. This ensures that any modification to one of these files will re-trigger a call
to //build/regenerator.
"""
result: T.Set[Path] = set()
for entry in bazel_content_hashes:
# Prepare hashing state
cipd_name = entry.get("cipd_name", None)
cipd_names = [cipd_name] if cipd_name else []
exclude_suffixes = entry.get("exclude_suffixes", [])
fstate = compute_content_hash.FileState(
cipd_names=cipd_names, exclude_suffixes=exclude_suffixes
)
# Hash each source path
for source_path in entry["source_paths"]:
input_path = interpret_gn_path(source_path, fuchsia_dir, build_dir)
fstate.hash_source_path(input_path)
# Write result into workspace, only update if it changed.
repo_name = entry["repo_name"]
output = output_dir / f"{repo_name}.hash"
output.parent.mkdir(parents=True, exist_ok=True)
if not output.exists() or output.read_text() != fstate.content_hash:
output.write_text(fstate.content_hash)
# Collect implicit inputs
result |= fstate.get_input_file_paths()
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--fuchsia-dir",
type=Path,
help="Fuchsia source directory (auto-detected).",
)
parser.add_argument(
"--fuchsia-build-dir",
type=Path,
help="Fuchsia build directory (auto-detected).",
)
parser.add_argument(
"--host-tag",
default=_DEFAULT_HOST_TAG,
help=f"Fuchsia host tag (default {_DEFAULT_HOST_TAG})",
)
parser.add_argument(
"--update-args",
action="store_true",
help="Update args.gn before regeneration.",
)
parser.add_argument(
"--symlinks-only",
action="store_true",
help="Only create convenience symlinks, do not regenerate build plan.",
)
parser.add_argument(
"--no-export-rust-project",
action="store_true",
help="Disable generation of the rust-project.json file.",
)
parser.add_argument(
"--color", action="store_true", help="Force colored output."
)
parser.add_argument(
"--gn-tracelog", type=Path, help="Path to GN --tracelog output file."
)
parser.add_argument(
"--gn-ide",
action="append",
default=[],
help="Pass value to GN --ide option.",
)
parser.add_argument(
"--gn-json-ide-script",
action="append",
default=[],
help="Pass value to GN --json-ide-script option.",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity.",
)
args = parser.parse_args()
verbose = args.verbose
def log(msg: str) -> None:
if verbose >= 1:
print(msg)
def log2(msg: str) -> None:
if verbose >= 2:
print(msg)
def run_cmd(
args: T.Sequence[str], **kwd: T.Any
) -> "subprocess.CompletedProcess[str]":
if verbose >= 2:
log("CMD: %s" % " ".join(shlex.quote(str(a)) for a in args))
return subprocess.run(args, text=True, **kwd)
fuchsia_dir = args.fuchsia_dir
if fuchsia_dir:
fuchsia_dir = fuchsia_dir.resolve()
else:
fuchsia_dir = workspace_utils.find_fuchsia_dir(_SCRIPT_DIR)
build_dir = args.fuchsia_build_dir
if build_dir:
build_dir = build_dir.resolve()
else:
build_dir = workspace_utils.find_fx_build_dir(fuchsia_dir)
if not build_dir:
parser.error(
"Cannot auto-detect build directory, please use --fuchsia-build-dir=DIR option!"
)
if not build_dir.is_dir():
parser.error(f"Build directory missing or invalid: {build_dir}")
if not args.symlinks_only:
# Run `gn gen` in fuchsia_dir to regenerate the Ninja build plan.
prebuilt_gn_subpath = f"prebuilt/third_party/gn/{args.host_tag}/gn"
prebuilt_ninja_subpath = (
f"prebuilt/third_party/ninja/{args.host_tag}/ninja"
)
build_dir_to_source_dir = os.path.relpath(fuchsia_dir, build_dir)
gn_cmd_args = [
prebuilt_gn_subpath,
"args" if args.update_args else "gen",
os.path.relpath(build_dir, fuchsia_dir),
"--fail-on-unused-args",
"--check=system",
f"--ninja-executable={fuchsia_dir}/{prebuilt_ninja_subpath}",
"--ninja-outputs-file=ninja_outputs.json",
]
if not args.no_export_rust_project:
gn_cmd_args += ["--export-rust-project"]
if args.color:
gn_cmd_args += ["--color"]
if args.gn_tracelog:
gn_cmd_args += [f"--tracelog={args.gn_tracelog}"]
for gn_ide in args.gn_ide:
gn_cmd_args += [f"--ide={gn_ide}"]
for gn_json_ide_script in args.gn_json_ide_script:
gn_cmd_args += [f"--json-ide-script={gn_json_ide_script}"]
log("Running gn gen to rebuild Ninja manifest...")
ret = run_cmd(gn_cmd_args, cwd=args.fuchsia_dir)
if ret.returncode != 0:
# Don't print anything here, assume GN already wrote something to the user.
return ret.returncode
log(
"Patching Ninja build plan to invoke regenerator on build file changes."
)
# Patch build.ninja to ensure Ninja calls this script instead of `gn gen`
# if any of the BUILD file changes.
log2("- Patching build.ninja")
build_ninja_path = build_dir / "build.ninja"
build_ninja = build_ninja_path.read_text()
pos = build_ninja.find("rule gn\n command = ")
if pos < 0:
print(
'ERROR: Cannot find "gn" rule in Ninja manifest!',
file=sys.stderr,
)
return 1
regenerator_command_args = [
sys.executable,
"-S",
f"{build_dir_to_source_dir}/build/regenerator.py",
f"--fuchsia-dir={shlex.quote(str(fuchsia_dir))}",
f"--fuchsia-build-dir={shlex.quote(str(build_dir))}",
f"--host-tag={args.host_tag}",
]
regenerator_command = " ".join(
shlex.quote(str(a)) for a in regenerator_command_args
)
pos2 = build_ninja.find("\n", pos + 16)
assert pos2 >= 0
build_ninja = (
build_ninja[:pos]
+ f"rule gn\n command = {regenerator_command}"
+ build_ninja[pos2:]
)
build_ninja_path.write_text(build_ninja)
# The list of extra inputs to add to the Ninja build plan.
extra_ninja_build_inputs: T.Set[Path] = set()
extra_ninja_build_inputs.add(fuchsia_dir / "build" / "regenerator")
extra_ninja_build_inputs.add(fuchsia_dir / "build" / "regenerator.py")
log("Generating product_bundles.json.")
product_bundles_metadata = build_dir / "product_bundles_metadata.json"
product_bundles = build_dir / "product_bundles.json"
if not product_bundles.exists() or not filecmp.cmp(
product_bundles_metadata, product_bundles
):
shutil.copy(product_bundles_metadata, product_bundles)
log("Generating tests.json.")
extra_ninja_build_inputs |= build_tests_json.build_tests_json(build_dir)
# Where to store regenerator outputs. This must be in a directory specific to
# the current Ninja build directory, to support building multiple Fuchsia build
# configurations concurrently on the same machine from the same checkout.
#
# For now, these are simply located in the Ninja build directory. As a consequence
# these files can only be used as implicit inputs from GN targets. Otherwise GN
# will complain that they belong to the root_build_dir, and that no other GN target
# generates them.
#
regenerator_outputs_dir = build_dir / "regenerator_outputs"
regenerator_outputs_dir.mkdir(parents=True, exist_ok=True)
# Generate content hash files for the Bazel workspace.
log("Generating Bazel content hash files")
# Load {build_dir}/bazel_content_hashes.json generated by `gn gen`.
# by //build/bazel:bazel_content_hashes_json
bazel_content_hashes_json = build_dir / "bazel_content_hashes.json"
assert (
bazel_content_hashes_json.exists()
), f"Missing file after `gn gen`: {bazel_content_hashes_json}"
with bazel_content_hashes_json.open("rb") as f:
bazel_content_hashes = json.load(f)
# Create corresponding content hash files under
# $BUILD_DIR/regenerator_outputs/bazel_content_hashes/
extra_ninja_build_inputs |= generate_bazel_content_hash_files(
fuchsia_dir,
build_dir,
regenerator_outputs_dir / "bazel_content_hashes",
bazel_content_hashes,
)
# Generate remote_services.bazelrc
log("Generating remote_services.bazelrc")
# NOTE: Since this file is generated by `gn gen`, it doesn't need
# to be added to extra_ninja_build_inputs.
# LINT.IfChange
with (build_dir / "rbe_settings.json").open("rb") as f:
rbe_settings = json.load(f)
remote_download_outputs = rbe_settings["final"][
"bazel_download_outputs"
]
# LINT.ThenChange(//build/rbe/BUILD.gn)
remote_services_bazelrc_file = (
regenerator_outputs_dir / "remote_services.bazelrc"
)
extra_ninja_build_inputs |= (
remote_services_utils.generate_remote_services_bazelrc(
fuchsia_dir=fuchsia_dir,
output_path=remote_services_bazelrc_file,
download_outputs=remote_download_outputs,
)
)
# Generate the content of the @fuchsia_build_info directory.
log("Generating @fuchsia_build_info content")
extra_ninja_build_inputs |= (
workspace_utils.GnBuildArgs.generate_fuchsia_build_info(
fuchsia_dir=fuchsia_dir,
build_dir=build_dir,
repository_dir=regenerator_outputs_dir / "fuchsia_build_info",
)
)
# Generate the bazel launcher and Bazel workspace files.
log("Generating Fuchsia Bazel workspace and launcher")
extra_ninja_build_inputs |= workspace_utils.generate_fuchsia_workspace(
fuchsia_dir=fuchsia_dir,
build_dir=build_dir,
log=log2,
enable_bzlmod=False,
)
# Find all imported Python modules and set them as extra inputs
# for safety. This avoids the need to manually track them, which
# is error-prone.
extra_ninja_build_inputs |= {
Path(m.__file__)
for m in sys.modules.values()
if hasattr(m, "__file__")
}
sorted_extra_ninja_build_inputs = sorted(
os.path.relpath(p, build_dir) for p in extra_ninja_build_inputs
)
if verbose >= 2:
print("- Extra Ninja inputs:")
for path in sorted_extra_ninja_build_inputs:
print(f" {path}")
# Patch build.ninja.d to ensure that Ninja will re-invoke the script if its
# own source code, or any other extra implicit input, changes.
log2("- Patching build.ninja.d")
build_ninja_d_path = build_dir / "build.ninja.d"
build_ninja_d = build_ninja_d_path.read_text().rstrip()
for extra_input in sorted_extra_ninja_build_inputs:
build_ninja_d += f" {extra_input}"
build_ninja_d_path.write_text(build_ninja_d)
log2("- Updating build.ninja.stamp timestamp")
build_ninja_stamp_path = build_dir / "build.ninja.stamp"
build_ninja_stamp_path.touch()
# Create convenience symlinks in fuchsia_dir, these should only be used by
# developers manually or by IDEs, but the build should never depend on them.
def make_relative_symlink(link_path: Path, target_path: Path) -> None:
if link_path.is_symlink() or link_path.exists():
link_path.unlink()
relative_target = os.path.relpath(
target_path.resolve(), link_path.parent.resolve()
)
log2(f"- Symlink {link_path} --> {relative_target}")
link_path.symlink_to(relative_target)
log("Creating convenience symlinks.")
# These artifacts need to be in the source checkout.
# NOTE: Nothing should depend on this in the build, as some developers
# need to run multiple builds concurrently from the same source checkout
# but using different build directories.
# These symlinks are useful for IDEs.
for artifact in ("compile_commands.json", "rust-project.json"):
if (build_dir / artifact).exists():
make_relative_symlink(fuchsia_dir / artifact, build_dir / artifact)
# These symlink help debug Bazel issues. See //build/bazel/README.md
make_relative_symlink(
fuchsia_dir / "bazel-workspace",
build_dir / "gen" / "build" / "bazel" / "workspace",
)
make_relative_symlink(
fuchsia_dir / "bazel-bin",
build_dir / "gen" / "build" / "bazel" / "workspace" / "bazel-bin",
)
make_relative_symlink(
fuchsia_dir / "bazel-out",
build_dir / "gen" / "build" / "bazel" / "workspace" / "bazel-out",
)
make_relative_symlink(
fuchsia_dir / "bazel-repos",
build_dir / "gen" / "build" / "bazel" / "output_base" / "external",
)
log("Done.")
return 0
if __name__ == "__main__":
sys.exit(main())