blob: 8b89ab250c4a18691cb211c1a1a13fc05c0015b0 [file] [log] [blame] [edit]
#!/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"
# The root directory of Bazel build scripts.
_BUILD_BAZEL_DIR = _SCRIPT_DIR / ".." / "build" / "bazel"
# The directory that contains Python modules related to the IDK.
_BUILD_SDK_SCRIPTS = _SCRIPT_DIR / ".." / "build" / "sdk"
sys.path.insert(0, str(_BUILD_BAZEL_SCRIPTS))
sys.path.insert(0, str(_BUILD_BAZEL_DIR))
sys.path.insert(0, str(_BUILD_SDK_SCRIPTS))
import build_utils
import compute_content_hash
import remote_services_utils
import workspace_utils
from fuchsia_idk import generate_repository
from generate_prebuild_idk import IdkGenerator
_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.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help=(
"Increase verbosity. (To change the verbosity of this script "
"during `fx build`, run `fx gen` with the desired instances of "
"this argument.)"
),
)
group.add_argument(
"-q",
"--quiet",
action="store_true",
help="Do not print time profile. Conflicts with --verbose.",
)
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 = build_utils.find_fuchsia_dir(_SCRIPT_DIR)
build_dir = args.fuchsia_build_dir
if build_dir:
build_dir = build_dir.resolve()
else:
build_dir = build_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}")
# Files that need to be deleted upon failure of regenerate().
build_ninja_stamp_path = build_dir / "build.ninja.stamp"
regenerator_outputs_dir = build_dir / "regenerator_outputs"
regenerator_inputs_path = regenerator_outputs_dir / "regenerator_inputs.txt"
def regenerate() -> int:
"""Do the regeneration work.
If this function fails, call ensure_regenerator_will_run_again() to
ensure this script will be run again in the next build after the issue
is resolved.
Returns:
0 upon success and a positive integer otherwise.
"""
# 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}"]
if args.quiet:
gn_cmd_args += ["-q"]
time_profile = build_utils.TimeProfile(log=log)
time_profile.start(
"gn gen", "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
# Patch build.ninja to ensure Ninja calls this script instead of `gn gen`
# if any of the BUILD file changes.
# Do this as early as possible to that this script will be re-invoked if
# it later fails.
time_profile.start(
"patching build.ninja",
"Patching Ninja build plan to invoke regenerator on 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",
# Ensure the Fuchsia checkout and build directories are relative to the
# current directory to avoid confusing Ninja when they are moved or
# copied to a different location. See https://fxbug.dev/401221699
f"--fuchsia-dir={build_dir_to_source_dir}",
"--fuchsia-build-dir=.",
f"--host-tag={args.host_tag}",
]
# Apply the current verbosity when Ninja calls this script.
# Note that this can only be changed by running `fx gen` because any
# call by Ninja will always pass the arguments specified the last time
# this script was run, regardless of the arguments to `fx build`.
if verbose >= 1:
regenerator_command_args += ["--verbose"]
if verbose >= 2:
regenerator_command_args += ["--verbose"]
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)
# From this point forward, an error will cause this script to run again
# on the next fx build. This is because build.ninja has been patched
# and build.ninja.stamp will be deleted upon error.
# 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")
time_profile.start(
"generate_prebuild_idk",
"Generating IDK export directory for Bazel in-tree SDK from GN prebuild metadata.",
)
with Path(
# LINT.IfChange
f"{build_dir}/sdk/prebuild/in_tree_collection.json"
# LINT.ThenChange(//build/bazel/scripts/workspace_utils.py)
).open() as f:
prebuild_manifest = json.load(f)
idk_generator = IdkGenerator(prebuild_manifest, build_dir, fuchsia_dir)
result, additional_files_read = idk_generator.GenerateMetaFileContents()
if result != 0:
print(
"ERROR: Failed to generate in-tree IDK meta file contents from prebuild metadata!",
file=sys.stderr,
)
return result
extra_ninja_build_inputs |= {Path(p) for p in additional_files_read}
idk_export_dir_path = Path(
f"{build_dir}/regenerator_outputs/bazel_in_tree_idk"
)
result, _ = idk_generator.WriteIdkContentsToDirectory(
idk_export_dir_path
)
if result != 0:
print(
"ERROR: Failed to generate in-tree IDK export directory from prebuild metadata!",
file=sys.stderr,
)
return result
time_profile.start(
"@fuchsia_in_tree_idk",
"Generating @fuchsia_in_tree_idk repository content.",
)
# LINT.IfChange
_idk_repository_name = "fuchsia_in_tree_idk"
_canonical_idk_repository_name = "+fuchsia_idk+fuchsia_in_tree_idk"
idk_repository_path = Path(
f"{build_dir}/regenerator_outputs/fuchsia_in_tree_idk"
)
# LINT.ThenChange(//build/bazel/toplevel.WORKSPACE.bzlmod)
generate_repository.GenerateIdkRepository(
_canonical_idk_repository_name,
idk_repository_path,
idk_export_dir_path,
build_dir,
)
with (idk_repository_path / "WORKSPACE.bazel").open("wt") as f:
f.write(f'workspace(name = "{_idk_repository_name}")\n')
with (idk_repository_path / "MODULE.bazel").open("wt") as f:
f.write(f'module(name = "{_idk_repository_name}", version = "1")\n')
ninja_idk_export_dir_symlink_path = (
idk_repository_path / "ninja_idk_export_dir_symlink"
)
if (
ninja_idk_export_dir_symlink_path.exists()
or ninja_idk_export_dir_symlink_path.is_symlink()
):
os.remove(ninja_idk_export_dir_symlink_path)
os.symlink(idk_export_dir_path, ninja_idk_export_dir_symlink_path)
time_profile.start(
"product_bundles.json", "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)
time_profile.start("tests.json", "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.mkdir(parents=True, exist_ok=True)
# Generate content hash files for the Bazel workspace.
time_profile.start(
"bazel_content_hash_files", "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
time_profile.start(
"remote_services.bazelrc", "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.
# `extra_ninja_inputs` does not need to be updated because the content
# is derived from GN args, and any changes would cause GN gen to run.
time_profile.start(
"@fuchsia_build_info", "Generating @fuchsia_build_info content"
)
workspace_utils.GnBuildArgs.generate_fuchsia_build_info(
fuchsia_dir=fuchsia_dir,
build_dir=build_dir,
# LINT.IfChange
repository_dir=regenerator_outputs_dir / "fuchsia_build_info",
# LINT.ThenChange(//build/bazel/scripts/workspace_utils.py)
)
# Generate the bazel launcher and Bazel workspace files.
time_profile.start(
"bazel_workspace", "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,
)
# 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__) # type: ignore
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}")
# Write the set of extra inputs to a file, this is used to ensure
# that `fx bazel` can invoke regenerator if any of these changes,
# independent of Ninja-related changes.
time_profile.start("regenerator_inputs.txt")
log2("- Writing regenerator_inputs.txt")
with regenerator_inputs_path.open("wt") as f:
for input_path in sorted_extra_ninja_build_inputs:
f.write(f"{input_path}\n")
# 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.
time_profile.start("build.ninja.d")
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.touch()
if not args.quiet:
time_profile.print(0.0 if verbose >= 1 else 0.5)
return 0
def ensure_regenerator_will_run_again() -> None:
"""Ensures this script run again the next time a build is attempted.
Deletes files used by Ninja and `fx bazel` to determine whether the
workspace needs to be regenerated. Call to ensure regeneration is run
again if it failed. Otherwise, for example, Ninja will see the updated
timestamp from GN gen and skip regeneration.
"""
if build_ninja_stamp_path.exists():
os.remove(build_ninja_stamp_path)
if regenerator_inputs_path.exists():
os.remove(regenerator_inputs_path)
# 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)
def create_convenience_symlinks() -> None:
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",
"pyrightconfig.base.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",
)
if not args.symlinks_only:
result = regenerate()
if result != 0:
# A message will have been printed before returning the error.
log("Regeneration failed")
ensure_regenerator_will_run_again()
return result
create_convenience_symlinks()
# These symlinks point out/default to the active build directory for
# developer convenience
if build_dir.name != "default":
out_default = fuchsia_dir / "out" / "default"
# if out/default was previously a hard directory
if out_default.exists() and not out_default.is_symlink():
old_dir = out_default
new_dir = old_dir.parent / (old_dir.name + "-migrated")
counter = 1
while os.path.exists(new_dir):
new_dir = old_dir.parent / f"{old_dir.name}-migrated{counter}"
counter += 1
old_dir.rename(new_dir)
print(f"[INFO] Moved out/default to out/{new_dir.name}")
# make out/dir a symlink of the buildDir for backwards compatibility
make_relative_symlink(out_default, build_dir)
log("Done.")
return 0
if __name__ == "__main__":
sys.exit(main())