| # 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. |
| |
| """Misc utility functions related to the Bazel workspace.""" |
| |
| import json |
| import os |
| import shlex |
| import stat |
| import sys |
| import typing as T |
| from pathlib import Path |
| |
| _SCRIPT_DIR = os.path.dirname(__file__) |
| sys.path.insert(0, _SCRIPT_DIR) |
| import build_utils |
| |
| # LINT.IfChange(gn_targets_dir_symlink) |
| # Location of the @gn_targets redirection symlink, relative |
| # to the Bazel workspace. |
| GN_TARGETS_DIR_SYMLINK = "fuchsia_build_generated/gn_targets_dir" |
| # LINT.ThenChange(//build/bazel/toplevel.MODULE.bazel:gn_targets_dir) |
| |
| # Separation character used by Bazel for extension-generated repo names. |
| # |
| # See https://bazel.build/external/extension#repository_names_and_visibility |
| _BAZEL_REPO_NAME_SEPARATOR = "+" |
| |
| |
| def workspace_should_exclude_file(path: str) -> bool: |
| """Return true if a file path must be excluded from the symlink list. |
| |
| Args: |
| path: File path, relative to the top-level Fuchsia directory. |
| Returns: |
| True if this file path should not be symlinked into the Bazel workspace. |
| """ |
| # Never symlink to the 'out' directory. |
| if path == "out": |
| return True |
| # Don't symlink the Jiri files, this can confuse Jiri during an 'jiri update' |
| # Don't symlink the .fx directory (TODO(digit): I don't remember why?) |
| # Don“t symlink the .git directory as well, since it needs to be handled separately. |
| if path.startswith((".jiri", ".fx", ".git")): |
| return True |
| # Don't symlink the convenience symlinks from the Fuchsia source tree |
| if path in ("bazel-bin", "bazel-out", "bazel-repos", "bazel-workspace"): |
| return True |
| return False |
| |
| |
| def make_removable(path: str) -> None: |
| """Ensure the file at |path| is removable.""" |
| |
| islink = os.path.islink(path) |
| |
| # Skip if the input path is a symlink, and chmod with |
| # `follow_symlinks=False` is not supported. Linux is the most notable |
| # platform that meets this requirement, and adding S_IWUSR is not necessary |
| # for removing symlinks. |
| if islink and (os.chmod not in os.supports_follow_symlinks): |
| return |
| |
| info = os.stat(path, follow_symlinks=False) |
| if info.st_mode & stat.S_IWUSR == 0: |
| try: |
| if islink: |
| os.chmod( |
| path, info.st_mode | stat.S_IWUSR, follow_symlinks=False |
| ) |
| else: |
| os.chmod(path, info.st_mode | stat.S_IWUSR) |
| except Exception as e: |
| raise RuntimeError( |
| f"Failed to chmod +w to {path}, islink: {islink}, info: {info}, error: {e}" |
| ) |
| |
| |
| def remove_dir(path: str) -> None: |
| """Properly remove a directory.""" |
| # shutil.rmtree() does not work well when there are readonly symlinks to |
| # directories. This results in weird NotADirectory error when trying to |
| # call os.scandir() or os.rmdir() on them (which happens internally). |
| # |
| # Re-implement it correctly here. This is not as secure as it could |
| # (see "shutil.rmtree symlink attack"), but is sufficient for the Fuchsia |
| # build. |
| all_files = [] |
| all_dirs = [] |
| for root, subdirs, files in os.walk(path): |
| # subdirs may contain actual symlinks which should be treated as |
| # files here. |
| real_subdirs = [] |
| for subdir in subdirs: |
| if os.path.islink(os.path.join(root, subdir)): |
| files.append(subdir) |
| else: |
| real_subdirs.append(subdir) |
| |
| for file in files: |
| file_path = os.path.join(root, file) |
| all_files.append(file_path) |
| make_removable(file_path) |
| for subdir in real_subdirs: |
| dir_path = os.path.join(root, subdir) |
| all_dirs.append(dir_path) |
| make_removable(dir_path) |
| |
| for file in reversed(all_files): |
| os.remove(file) |
| for dir in reversed(all_dirs): |
| os.rmdir(dir) |
| os.rmdir(path) |
| |
| |
| def create_clean_dir(path: str) -> None: |
| """Create a clean directory.""" |
| if os.path.exists(path): |
| remove_dir(path) |
| os.makedirs(path) |
| |
| |
| # Type describing a callable that takes a file path as input and |
| # returns a content hash string for it as output. |
| FileHasherType: T.TypeAlias = T.Callable[[str | Path], str] |
| |
| |
| class GeneratedWorkspaceFiles(object): |
| """Models the content of a generated Bazel workspace. |
| |
| Usage is: |
| 1. Create instance |
| |
| 2. Optionally call set_file_hasher() if recording the content hash |
| of input files is useful (see add_input_file()). |
| |
| 3. Call the record_xxx() methods as many times as necessary to |
| describe new workspace entries. This does not write anything |
| to disk. |
| |
| 4. Call to_json() to return a dictionary describing all added entries |
| so far. This can be used to compare it to the result of a previous |
| workspace generation. |
| |
| 5. Call write() to populate the workspace with the recorded entries. |
| """ |
| |
| def __init__(self) -> None: |
| self._files: dict[str, T.Any] = {} |
| self._file_hasher: T.Optional[FileHasherType] = None |
| self._input_files: set[Path] = set() |
| |
| def set_file_hasher(self, file_hasher: FileHasherType) -> None: |
| self._file_hasher = file_hasher |
| |
| def _check_new_path(self, path: str) -> None: |
| assert path not in self._files, ( |
| "File entry already in generated list: " + path |
| ) |
| |
| @property |
| def input_files(self) -> set[Path]: |
| """The set of input file Paths that were read through read_text_file().""" |
| return self._input_files |
| |
| def read_text_file(self, path: Path) -> str: |
| """Read an input file and return its content as text. |
| |
| This also ensures that the file is tracked as an input file |
| to later be returned through the self.input_files property. |
| |
| Args: |
| path: Input file path. |
| Returns: |
| content of input path, as a string. |
| """ |
| path = path.resolve() |
| self._input_files.add(path) |
| self.record_input_file_hash(path) |
| return path.read_text() |
| |
| def record_symlink(self, dst_path: str, target_path: str | Path) -> None: |
| """Record a new symlink entry. |
| |
| Note that the entry always generates a relative symlink target |
| when writing the entry to the workspace in write(), even if |
| target_path is absolute. |
| |
| Args: |
| dst_path: symlink path, relative to workspace root. |
| target_path: symlink target path. |
| """ |
| self._check_new_path(dst_path) |
| self._files[dst_path] = { |
| "type": "symlink", |
| "target": str(target_path), |
| } |
| |
| def record_raw_symlink( |
| self, dst_path: str, target_path: str | Path |
| ) -> None: |
| """Record a new symlink entry. |
| |
| Note that the entry always generates a relative symlink target |
| when writing the entry to the workspace in write(), even if |
| target_path is absolute. |
| |
| Args: |
| dst_path: symlink path, relative to workspace root. |
| target_path: symlink target path. |
| """ |
| self._check_new_path(dst_path) |
| self._files[dst_path] = { |
| "type": "raw_symlink", |
| "target": str(target_path), |
| } |
| |
| def record_file_content( |
| self, dst_path: str, content: str, executable: bool = False |
| ) -> None: |
| """Record a new data file entry. |
| |
| Args: |
| dst_path: file path, relative to workspace root. |
| content: file content as a string. |
| executable: optional flag, set to True to indicate this is an |
| executable file (i.e. a script). |
| """ |
| self._check_new_path(dst_path) |
| entry: dict[str, T.Any] = { |
| "type": "file", |
| "content": content, |
| } |
| if executable: |
| entry["executable"] = True |
| self._files[dst_path] = entry |
| |
| def record_input_file_hash(self, input_path: str | Path) -> None: |
| """Record the content hash of an input file. |
| |
| If set_file_hash_callback() was called, compute the content hash of |
| a given input file path and record it. Note that nothing will be written |
| to the workspace. This is only useful when comparing the output of |
| to_json() between different generations to detect when the input file |
| has changed. |
| """ |
| input_file = str(input_path) |
| self._check_new_path(input_file) |
| if self._file_hasher: |
| self._files[input_file] = { |
| "type": "input_file", |
| "hash": self._file_hasher(input_path), |
| } |
| |
| def to_json(self) -> str: |
| """Convert recorded entries to JSON string.""" |
| return json.dumps(self._files, indent=2, sort_keys=True) |
| |
| def write(self, out_dir: str | Path) -> None: |
| """Write all recorded entries to a workspace directory.""" |
| for path, entry in self._files.items(): |
| type = entry["type"] |
| if type == "symlink": |
| target_path = entry["target"] |
| link_path = os.path.join(out_dir, path) |
| build_utils.force_symlink(link_path, target_path) |
| elif type == "raw_symlink": |
| target_path = entry["target"] |
| link_path = os.path.join(out_dir, path) |
| build_utils.force_raw_symlink(link_path, target_path) |
| elif type == "file": |
| file_path = os.path.join(out_dir, path) |
| os.makedirs(os.path.dirname(file_path), exist_ok=True) |
| with open(file_path, "w") as f: |
| f.write(entry["content"]) |
| if entry.get("executable", False): |
| os.chmod(file_path, 0o755) |
| elif type == "input_file": |
| # Nothing to do here. |
| pass |
| else: |
| assert False, "Unknown entry type: " % entry["type"] |
| |
| def update_if_needed(self, out_dir: Path, manifest_path: Path) -> bool: |
| """Write all recorded entries if they differ from the content of a given manifest. |
| |
| If the manifest exists and its content matches the recorded entries, |
| then this method does not do anything. Otherwise, it will overwrite |
| the manifests with new values, clean the output directory, and re-populate |
| it entirely with new content. |
| |
| Args: |
| out_dir: Output directory to update if needed. |
| manifest_path: Path to manifest file to use for comparisons. |
| Returns: |
| True if the output directory and manifest were updated, False otherwise. |
| """ |
| current_manifest = self.to_json() |
| if out_dir.is_dir() and manifest_path.exists(): |
| if manifest_path.read_text() == current_manifest: |
| # Nothing to change here. |
| return False |
| |
| manifest_path.write_text(current_manifest) |
| create_clean_dir(str(out_dir)) |
| self.write(out_dir) |
| return True |
| |
| |
| def record_fuchsia_workspace( |
| generated: GeneratedWorkspaceFiles, |
| top_dir: Path, |
| fuchsia_dir: Path, |
| gn_output_dir: Path, |
| git_bin_path: Path, |
| log: T.Optional[T.Callable[[str], None]] = None, |
| ) -> None: |
| """Record generated entries for the Fuchsia workspace and helper files. |
| |
| Note that this hards-code a few paths, i.e.: |
| |
| - The generated Bazel wrapper script is written to ${top_dir}/bazel |
| - Bazel output base is at ${top_dir}/output_base |
| - Bazel output user root is at ${top_dir}/output_user_root |
| - The workspace goes into ${top_dir}/workspace/ |
| - Logs are written to ${top_dir}/logs/ |
| |
| Args: |
| generated: A GeneratedWorkspaceFiles instance modified by this function. |
| top_dir: Path to the top-level |
| fuchsia_dir: Path to the Fuchsia source checkout. |
| gn_output_dir: Path to the GN/Ninja output directory. |
| git_bin_path: Path to the host git binary to use during the build. |
| log: Optional logging callback. If not None, must take a single |
| string as argument. |
| """ |
| |
| host_os = build_utils.get_host_platform() |
| host_cpu = build_utils.get_host_arch() |
| host_tag = build_utils.get_host_tag() |
| |
| templates_dir = fuchsia_dir / "build" / "bazel" / "templates" |
| |
| logs_dir = top_dir / "logs" |
| |
| ninja_binary = ( |
| fuchsia_dir / "prebuilt" / "third_party" / "ninja" / host_tag / "ninja" |
| ) |
| bazel_bin = ( |
| fuchsia_dir / "prebuilt" / "third_party" / "bazel" / host_tag / "bazel" |
| ) |
| python_prebuilt_dir = ( |
| fuchsia_dir / "prebuilt" / "third_party" / "python3" / host_tag |
| ) |
| output_base_dir = top_dir / build_utils.BazelPaths.OUTPUT_BASE_FROM_TOP_DIR |
| output_user_root = ( |
| top_dir / build_utils.BazelPaths.OUTPUT_USER_ROOT_FROM_TOP_DIR |
| ) |
| workspace_dir = top_dir / build_utils.BazelPaths.WORKSPACE_FROM_TOP_DIR |
| bazel_launcher = ( |
| top_dir / build_utils.BazelPaths.LAUNCHER_FROM_TOP_DIR |
| ).resolve() |
| |
| if log: |
| log( |
| f"""- Using directories and files: |
| Fuchsia: {fuchsia_dir} |
| GN build: {gn_output_dir} |
| Ninja binary: {ninja_binary} |
| Bazel source: {bazel_bin} |
| Top dir: {top_dir} |
| Logs directory: {logs_dir} |
| Bazel workspace: {workspace_dir} |
| Bazel output_base: {output_base_dir} |
| Bazel output user root: {output_user_root} |
| Bazel launcher: {bazel_launcher} |
| Git binary path: {git_bin_path}""" |
| ) |
| |
| def expand_template_file( |
| generated: GeneratedWorkspaceFiles, filename: str, **kwargs: T.Any |
| ) -> str: |
| """Expand a template file and add it to the set of tracked input files.""" |
| template_file = templates_dir / filename |
| return generated.read_text_file(template_file).format(**kwargs) |
| |
| def record_expanded_template( |
| generated: GeneratedWorkspaceFiles, |
| dst_path: str, |
| template_name: str, |
| **kwargs: T.Any, |
| ) -> None: |
| """Expand a template file and record its content. |
| |
| Args: |
| generated: A GeneratedWorkspaceFiles instance. |
| dst_path: Destination path for the recorded expanded content. |
| template_name: Name of the template file to use. |
| executable: Optional flag, set to True to indicate an executable file. |
| **kwargs: Template expansion arguments. |
| """ |
| executable = kwargs.get("executable", False) |
| kwargs.pop("executable", None) |
| content = expand_template_file(generated, template_name, **kwargs) |
| generated.record_file_content(dst_path, content, executable=executable) |
| |
| generated.record_symlink( |
| "workspace/MODULE.bazel", |
| fuchsia_dir / "build" / "bazel" / "toplevel.MODULE.bazel", |
| ) |
| |
| generated.record_symlink( |
| "workspace/BUILD.bazel", |
| fuchsia_dir / "build" / "bazel" / "toplevel.BUILD.bazel", |
| ) |
| |
| # Generate top-level symlinks |
| for name in os.listdir(fuchsia_dir): |
| if not workspace_should_exclude_file(name): |
| generated.record_symlink(f"workspace/{name}", fuchsia_dir / name) |
| |
| # The top-level .git directory must be symlinked because some actions actually |
| # launch git commands (e.g. to generate a build version identifier). On the other |
| # hand Jiri will complain if it finds a .git repository with Jiri metadata that |
| # it doesn't know about in its manifest. The error looks like: |
| # |
| # ``` |
| # [17:49:48.200] WARN: Project "fuchsia" has path /work/fx-bazel-build, but was found in /work/fx-bazel-build/out/default/gen/build/bazel/output_base/execroot/main. |
| # jiri will treat it as a stale project. To remove this warning please delete this or move it out of your root folder |
| # ``` |
| # |
| # Looking at the Jiri sources reveals that it is looking at a `.git/jiri` sub-directory |
| # in all git directories it finds during a `jiri update` operation. To avoid the complaint |
| # then symlink all $FUCHSIA_DIR/.git/ files and directories, except the 'jiri' one. |
| # Also ignore the JIRI_HEAD / JIRI_LAST_BASE files to avoid confusion. |
| fuchsia_git_dir = fuchsia_dir / ".git" |
| for git_file in os.listdir(fuchsia_git_dir): |
| if not (git_file == "jiri" or git_file.startswith("JIRI")): |
| generated.record_symlink( |
| "workspace/.git/" + git_file, fuchsia_git_dir / git_file |
| ) |
| |
| # Generate a platform mapping file to ensure that using --platforms=<value> |
| # also sets --cpu properly, as required by the Bazel SDK rules. See comments |
| # in template file for more details. |
| _BAZEL_CPU_MAP = {"x64": "k8", "arm64": "aarch64"} |
| host_os = build_utils.get_host_platform() |
| host_cpu = build_utils.get_host_arch() |
| host_tag = build_utils.get_host_tag() |
| |
| record_expanded_template( |
| generated, |
| "workspace/platform_mappings", |
| "template.platform_mappings", |
| bazel_host_cpu=_BAZEL_CPU_MAP.get(host_cpu, host_cpu), |
| host_cpu=host_cpu, |
| host_os=host_os, |
| ) |
| |
| # Generate the content of .bazelrc |
| logs_dir = top_dir / "logs" |
| logs_dir_from_workspace = os.path.relpath(logs_dir, top_dir / "workspace") |
| bazelrc_content = expand_template_file( |
| generated, |
| "template.bazelrc", |
| workspace_log_file=f"{logs_dir_from_workspace}/workspace_events.log", |
| execution_log_file=f"{logs_dir_from_workspace}/exec_log.pb.zstd", |
| ) |
| |
| generated.record_file_content("workspace/.bazelrc", bazelrc_content) |
| |
| # Copy the wrapper script to topdir/bazel, and generate the configuration |
| # file at topdir/bazel.sh.config that is required to run it. |
| # Generate wrapper script in topdir/bazel that invokes Bazel with the right --output_base. |
| |
| generated.record_symlink( |
| "bazel", os.path.join(fuchsia_dir, "build/bazel/wrapper.bazel.sh") |
| ) |
| |
| def config_path_value(p: str) -> str: |
| return shlex.quote(os.path.abspath(p)) |
| |
| record_expanded_template( |
| generated, |
| # LINT.IfChange(bazel.sh.config) |
| "bazel.sh.config", |
| # LINT.ThenChange(//build/bazel/wrapper.bazel.sh) |
| "template.bazel.sh.config", |
| bazel_bin=config_path_value(bazel_bin), |
| bazel_log_dir=config_path_value(logs_dir), |
| bazel_output_base=config_path_value(output_base_dir), |
| bazel_output_user_root=config_path_value(output_user_root), |
| bazel_workspace_dir=config_path_value(workspace_dir), |
| ninja_build_dir=config_path_value(gn_output_dir), |
| prebuilt_ninja=config_path_value(ninja_binary), |
| prebuilt_python_dir=config_path_value(python_prebuilt_dir), |
| ) |
| |
| generated.record_symlink( |
| os.path.join( |
| "workspace", |
| "fuchsia_build_generated", |
| "assembly_developer_overrides.json", |
| ), |
| os.path.join(gn_output_dir, "gen", "assembly_developer_overrides.json"), |
| ) |
| |
| # LINT.IfChange |
| generated.record_symlink( |
| "workspace/fuchsia_build_generated/args.json", |
| gn_output_dir / "args.json", |
| ) |
| # LINT.ThenChange(//build/bazel_sdk/bazel_rules_fuchsia/common/fuchsia_platform_build.bzl) |
| |
| # Create a symlink to the git host executable to make it accessible |
| # when running a Bazel action on bots where it is not installed in |
| # a standard location. |
| generated.record_symlink( |
| "workspace/fuchsia_build_generated/git", |
| git_bin_path, |
| ) |
| |
| # LINT.IfChange |
| # .jiri_root/ is not exposed to the workspace, but //build/info/BUILD.bazel |
| # needs to access .jiri_root/update_history/latest so create a symlink just |
| # for this file. |
| generated.record_symlink( |
| "workspace/fuchsia_build_generated/jiri_snapshot.xml", |
| fuchsia_dir / ".jiri_root" / "update_history" / "latest", |
| ) |
| # LINT.ThenChange(//build/info/info.gni) |
| |
| generated.record_symlink( |
| "workspace/fuchsia_build_generated/content_hashes", |
| gn_output_dir / "regenerator_outputs" / "bazel_content_hashes", |
| ) |
| |
| # Used when merging the IDK sub-build directories. For other use cases, |
| # prefer a symlink with a narrower scope. |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/ninja_root_build_dir", |
| # LINT.ThenChange(//build/bazel/bazel_sdk/BUILD.bazel) |
| gn_output_dir, |
| ) |
| |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/fuchsia_in_tree_idk.hash", |
| # LINT.ThenChange(//build/bazel/bazel_sdk/BUILD.bazel, //build/bazel/toplevel.MODULE.bazel) |
| # LINT.IfChange |
| gn_output_dir / "sdk/prebuild/in_tree_collection.json", |
| # LINT.ThenChange(//build/regenerator.py) |
| ) |
| |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/fuchsia_internal_only_idk.hash", |
| # LINT.ThenChange(//build/bazel/toplevel.WORKSPACE.bzlmod) |
| # LINT.IfChange |
| gn_output_dir / "obj/build/bazel/fuchsia_internal_only_idk.hash", |
| # LINT.ThenChange(//build/bazel/BUILD.gn) |
| ) |
| |
| # The following symlinks are used only by bazel_action.py when processing |
| # the list of Bazel source inputs, the actual repository setup in |
| # MODULE.bazel reuses the two symlinks above instead. |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/fuchsia_sdk.hash", |
| # LINT.ThenChange(//build/bazel/scripts/bazel_action.py) |
| # LINT.IfChange |
| gn_output_dir / "sdk/prebuild/in_tree_collection.json", |
| # LINT.ThenChange(//build/regenerator.py) |
| ) |
| |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/internal_sdk.hash", |
| # LINT.ThenChange(//build/bazel/scripts/bazel_action.py) |
| # LINT.IfChange |
| gn_output_dir / "obj/build/bazel/fuchsia_internal_only_idk.hash", |
| # LINT.ThenChange(//build/bazel/BUILD.gn) |
| ) |
| |
| # Create a link to an empty repository. This is updated by bazel_action.py |
| # before each Bazel invocation to point to the @gn_targets content specific |
| # to its parent bazel_action() target. |
| generated.record_symlink( |
| f"workspace/{GN_TARGETS_DIR_SYMLINK}", |
| fuchsia_dir |
| / "build" |
| / "bazel" |
| / "local_repositories" |
| / "empty_gn_targets", |
| ) |
| |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/fuchsia_build_info", |
| # LINT.ThenChange(//build/bazel/toplevel.MODULE.bazel) |
| # LINT.IfChange |
| gn_output_dir / "regenerator_outputs/fuchsia_build_info", |
| # LINT.ThenChange(//build/regenerator.py) |
| ) |
| |
| generated.record_symlink( |
| # LINT.IfChange |
| "workspace/fuchsia_build_generated/fuchsia_in_tree_idk", |
| # LINT.ThenChange(//build/bazel/toplevel.MODULE.bazel) |
| # LINT.IfChange |
| gn_output_dir / "regenerator_outputs/fuchsia_in_tree_idk", |
| # LINT.ThenChange(//build/regenerator.py) |
| ) |
| |
| |
| def generate_fuchsia_workspace( |
| fuchsia_dir: Path, |
| build_dir: Path, |
| log: T.Optional[T.Callable[[str], None]] = None, |
| ) -> set[Path]: |
| """Generate the Fuchsia Bazel workspace and associated files. |
| |
| Args: |
| fuchsia_dir: Path to the Fuchsia source checkout. |
| build_dir: Path to the GN/Ninja output directory. |
| log: Optional logging callback. If not None, must take a single |
| string as argument. |
| |
| Returns: |
| A set of input file paths that were read by this function. |
| """ |
| # Find path of host git tool |
| git_bin_path = build_utils.find_host_binary_path("git") |
| assert git_bin_path, f"Could not find 'git' in current PATH!" |
| |
| # # Extract target_cpu from args.json. This file is GN-generated |
| # # and doesn't need to be added to input_files. |
| # args_json_path = build_dir / "args.json" |
| # assert args_json_path.exists(), f"Missing GN output file: {args_json_path}" |
| # with args_json_path.open("rb") as f: |
| # args_json = json.load(f) |
| # |
| # target_cpu = args_json.get("target_cpu") |
| # assert target_cpu, f"Missing target_cpu key in {args_json_path}" |
| |
| # Find the location of the Bazel top-dir relative to the Ninja |
| # build directory. |
| bazel_top_dir, input_files = build_utils.get_bazel_relative_topdir( |
| fuchsia_dir |
| ) |
| |
| # Generate the bazel launcher and Bazel workspace files. |
| generated = GeneratedWorkspaceFiles() |
| |
| top_dir = build_dir / bazel_top_dir |
| |
| record_fuchsia_workspace( |
| generated, |
| top_dir=top_dir, |
| fuchsia_dir=fuchsia_dir, |
| gn_output_dir=build_dir, |
| git_bin_path=git_bin_path, |
| log=log, |
| ) |
| |
| # Remove the old workspace's content, which is just a set |
| # of symlinks and some auto-generated files. |
| create_clean_dir(str(top_dir / "workspace")) |
| |
| # Do not clean the output_base because it is now massive, |
| # and doing this will very slow and will force a lot of |
| # unnecessary rebuilds after that, |
| # |
| # However, do remove $OUTPUT_BASE/external/ which holds |
| # the content of external repositories. |
| # |
| # This is a guard against repository rules that do not |
| # list their input dependencies properly, and would fail |
| # to re-run if one of them is updated. Sadly, this is |
| # pretty common with Bazel. |
| create_clean_dir(os.path.join(top_dir / "output_base" / "external")) |
| |
| generated.write(top_dir) |
| |
| return input_files | generated.input_files |
| |
| |
| class GnBuildArgs(object): |
| """A class to handle `gn_build_variables_for_bazel.json` input files. |
| |
| These files are used to export the values of GN args to an `args.bzl` file |
| and optionally `vendor_<name>_args.bzl` files in the @fuchsia_build_info |
| Bazel repository. |
| |
| See the comments for //build/bazel:gn_build_variables_for_bazel for the |
| format of the `gn_build_variables_for_bazel.json` files. |
| |
| Vendor-specific files should always be placed at |
| $OUT_DIR/vendor_<name>_gn_build_variables_for_bazel.json |
| and the target that generates them must always be in the dependency graph |
| when building anything from the vendor repo. |
| """ |
| |
| @staticmethod |
| def find_all_gn_build_variables_for_bazel( |
| fuchsia_dir: Path, build_dir: Path |
| ) -> list[str]: |
| """Find `all gn_build_variables_for_bazel.json` files in the Fuchsia checkout. |
| |
| Args: |
| fuchsia_dir: Path to Fuchsia source dir. |
| build_dir: Path to Fuchsia build directory. |
| |
| Returns: |
| A list of `gn_build_variables_for_bazel.json` file paths relative to |
| the build directory root. |
| |
| Raises: |
| ValueError if a required input file is missing. |
| """ |
| args_files: list[str] = [] |
| |
| base_file_name = "gn_build_variables_for_bazel.json" |
| |
| main_file_path = build_dir / base_file_name |
| if not (main_file_path).exists(): |
| raise ValueError( |
| f"Missing required build arguments file: {main_file_path}" |
| ) |
| args_files.append(base_file_name) |
| |
| # The //vendor/ tree is optional and does not exist in open-source checkouts. |
| vendor_dir = fuchsia_dir / "vendor" |
| if vendor_dir.is_dir(): |
| for vendor_name in os.listdir(vendor_dir): |
| vendor_file = f"vendor_{vendor_name}_{base_file_name}" |
| if (build_dir / vendor_file).exists(): |
| args_files.append(vendor_file) |
| |
| return args_files |
| |
| @staticmethod |
| def generate_args_bzl( |
| gn_args_to_export: list[dict[str, T.Any]], args_json_path: Path |
| ) -> str: |
| """Generate an `args.bzl` file defining values extracted from GN's args. |
| |
| Args: |
| gn_args_to_export: A list of dictionaries describing each GN arg. |
| See comments for //build/bazel:gn_build_variables_for_bazel for |
| the format. |
| args_json_path: Path of source file for build_args (never accessed). |
| Returns: |
| The content of the generated `args.bzl` file as a string. |
| Raises: |
| ValueError if the input content is malformed. |
| """ |
| |
| def fail(msg: str) -> None: |
| raise ValueError(msg) |
| |
| args_contents = """# AUTO-GENERATED BY FUCHSIA BUILD - DO NOT EDIT |
| # Variables listed from {source_path} |
| |
| \"\"\"A subset of GN args that are needed in the Bazel build.\"\"\" |
| """.format( |
| source_path=args_json_path |
| ) |
| |
| for gn_arg in gn_args_to_export: |
| args_contents += "\n# From {}\n".format(gn_arg["location"]) |
| varname = gn_arg["name"] |
| vartype = gn_arg["type"] |
| value = gn_arg["value"] |
| if vartype in ["bool", "array_of_strings"]: |
| args_contents += "{} = {}".format(varname, value) |
| elif vartype in ["string", "string_or_false", "path"]: |
| if vartype == "string_or_false" and not value: |
| value = "" |
| elif vartype == "path": |
| if value.startswith("//"): |
| value = value[2:] |
| elif value.startswith("/"): |
| # Pass through the absolute path. |
| value = value |
| else: |
| fail( |
| "Path '{}' does not begin with '//' or '/'".format( |
| value, |
| ) |
| ) |
| |
| args_contents += '{} = "{}"'.format(varname, value) |
| else: |
| fail( |
| "Unknown type name '{}'".format( |
| vartype, |
| ) |
| ) |
| args_contents += "\n" |
| |
| return args_contents |
| |
| @staticmethod |
| def record_fuchsia_build_info_dir( |
| fuchsia_dir: Path, |
| build_dir: Path, |
| generated: GeneratedWorkspaceFiles, |
| ) -> None: |
| """Record the content of @fuchsia_build_info in a GeneratedWorkspaceFiles instance. |
| |
| Args: |
| fuchsia_dir: Path to Fuchsia source directory. |
| build_dir: Path to Fuchsia build directory. |
| generated: A GeneratedWorkspaceFiles instance. Its record_file_content() |
| method will be called to populate the repository. |
| """ |
| generated.record_file_content( |
| "MODULE.bazel", |
| 'module(name = "fuchsia_build_info", version = "1")', |
| ) |
| generated.record_file_content("BUILD.bazel", "") |
| |
| args_files_relative_paths = ( |
| GnBuildArgs.find_all_gn_build_variables_for_bazel( |
| fuchsia_dir, build_dir |
| ) |
| ) |
| |
| for relative_path in sorted(args_files_relative_paths): |
| file_path = build_dir / relative_path |
| with (file_path).open("rb") as f: |
| gn_args_json = json.load(f) |
| args_bzl_content = GnBuildArgs.generate_args_bzl( |
| gn_args_json, file_path |
| ) |
| |
| if relative_path.startswith("vendor_"): |
| vendor_name = relative_path.split("_")[1] |
| args_bzl_filename = f"vendor_{vendor_name}_args.bzl" |
| else: |
| args_bzl_filename = "args.bzl" |
| generated.record_file_content(args_bzl_filename, args_bzl_content) |
| |
| @staticmethod |
| def generate_fuchsia_build_info( |
| fuchsia_dir: Path, build_dir: Path, repository_dir: Path |
| ) -> None: |
| generated = GeneratedWorkspaceFiles() |
| GnBuildArgs.record_fuchsia_build_info_dir( |
| fuchsia_dir, build_dir, generated |
| ) |
| |
| generated.update_if_needed( |
| repository_dir, Path(f"{repository_dir}.generated-info.json") |
| ) |
| |
| |
| def record_gn_targets_dir( |
| generated: GeneratedWorkspaceFiles, |
| build_dir: Path, |
| inputs_manifest_path: Path, |
| all_licenses_spdx_path: Path, |
| ) -> None: |
| """Record the content of a @gn_targets directory in a GeneratedWorkspaceFiles instance. |
| |
| Args: |
| generated: A GeneratedWorkspaceFiles instance. |
| build_dir: Path to the Ninja build directory. |
| inputs_manifest_path: Path to an inputs manifest file generated |
| by the generate_gn_targets_repository_manifest() GN template. |
| See //build/bazel/bazel_inputs.gni comments for file format. |
| all_licenses_spdx_path: Path to an SPDX file listing all licensing |
| requirements for the inputs covered by the manifest. |
| Raises: |
| ValueError in case of missing or malformed input. |
| """ |
| # Ensure build_dir is absolute. Most symlink targets must be absolute for Bazel |
| # to work properly. |
| build_dir = build_dir.resolve() |
| |
| if not inputs_manifest_path.exists(): |
| raise ValueError( |
| f"Missing inputs manifest file: {inputs_manifest_path}" |
| ) |
| if not all_licenses_spdx_path.exists(): |
| raise ValueError( |
| f"Missing licensing information file: {all_licenses_spdx_path}" |
| ) |
| |
| # This creates two sets of symlinks. |
| # |
| # The first one maps `_files/{ninja_path}` to the absolute path of the corresponding |
| # Ninja artifact in the build directory, e.g.: |
| # |
| # _files/obj/src/foo/foo.cc.o ----> $NINJA_BUILD_DIR/obj/src/foo/foo.cc |
| # |
| # There is one such symlink per Ninja output paths. |
| # |
| # Second, for each bazel_package value, `{bazel_package}/_files` will be a relative |
| # symlink that points to the top-level `_files` directory, as in: |
| # |
| # src/foo/_files ---> ../../_files |
| # |
| # This is used by the BUILD.bazel file generated in the same sub-directory, that can |
| # reference the artifacts using labels like "_files/{ninja_path}" without having |
| # to care for Bazel package boundaries, as in: |
| # |
| # ``` |
| # # Generated as src/foo/BUILD.bazel |
| # filegroup( |
| # name = "foo", |
| # srcs = [ "_files/obj/src/foo/foo.cc.o" ] |
| # ) |
| # ``` |
| # |
| # The reason why this double indirection exists is purely for debuggability! |
| # It is easier to see all the Ninja artifacts exposed at once from the top-level |
| # _files/ directory when verifying correctness. |
| # |
| # It is perfectly possible to only place absollute symlinks under |
| # {bazel_package}/_files/... but doing this leads to repositories that are |
| # harder to inspect in practice due to the extra long paths it creates. |
| |
| # The top-level directory that will contain symlinks to all Ninja output |
| # files, using . For example _files/obj/src/foo/foo.cc.o |
| build_dir_name = "_files" |
| |
| all_files = [] |
| |
| # Build a { bazel_package -> { gn_target_name -> entry } } map. |
| package_map: dict[str, dict[str, str]] = {} |
| for entry in json.loads(generated.read_text_file(inputs_manifest_path)): |
| bazel_package = entry["bazel_package"] |
| bazel_name = entry["bazel_name"] |
| name_map = package_map.setdefault(bazel_package, {}) |
| name_map[bazel_name] = entry |
| |
| # Create the ///{gn_dir}/BUILD.bazel file for each GN directory. |
| # Every target defined in {gn_dir}/BUILD.gn that is part of the manifest |
| # will have its own filegroup() entry with the corresponding target name. |
| for bazel_package, name_map in package_map.items(): |
| content = """# AUTO-GENERATED - DO NOT EDIT |
| |
| package( |
| default_applicable_licenses = ["//:all_licenses_spdx_json"], |
| default_visibility = ["//visibility:public"], |
| ) |
| |
| """ |
| for bazel_name, entry in name_map.items(): |
| file_links = entry.get("output_files", []) |
| if file_links: |
| for file in file_links: |
| # Create //_files/{ninja_path} as a symlink to the Ninja output location. |
| target_file = build_dir / file |
| if not target_file.exists(): |
| target_file.parent.mkdir(parents=True, exist_ok=True) |
| target_file.write_text("") |
| generated.record_raw_symlink( |
| f"{build_dir_name}/{file}", |
| target_file, |
| ) |
| all_files.append(file) |
| |
| content += """ |
| # From GN target: {label} |
| filegroup( |
| name = "{name}", |
| srcs = """.format( |
| label=entry["generator_label"], name=bazel_name |
| ) |
| if len(file_links) == 1: |
| content += '["_files/%s"],\n' % file_links[0] |
| else: |
| content += "[\n" |
| for file in file_links: |
| content += ' "_files/%s",\n' % file |
| content += " ],\n" |
| content += ")\n" |
| |
| dir_link = entry.get("output_directory", "") |
| if dir_link: |
| # Create //_files/{ninja_path} as a symlink to the real path. |
| target_dir = build_dir / dir_link |
| generated.record_raw_symlink( |
| f"{build_dir_name}/{dir_link}", target_dir |
| ) |
| |
| # NOTE: allow_empty is necessary when performing queries |
| # before building anything. The glob() might return an empty |
| # list in this case since there is nothing built from Ninja yet. |
| content += """ |
| # From GN target: {label} |
| filegroup( |
| name = "{name}", |
| srcs = glob(["{ninja_path}/**"], exclude_directories=1, allow_empty=True), |
| ) |
| alias( |
| name = "{name}.directory", |
| actual = "{ninja_path}", |
| ) |
| """.format( |
| label=entry["generator_label"], |
| name=bazel_name, |
| ninja_path=f"{build_dir_name}/{dir_link}", |
| ) |
| |
| generated.record_file_content(f"{bazel_package}/BUILD.bazel", content) |
| |
| # Because {bazel_package}/BUILD.bazel contains label references |
| # |
| # such as "_files/obj/src/tee/ta/noop/ta-noop.far", create |
| # {bazel_package}/_files as a symlink to the top-level _files directory. |
| # |
| # A relative path target is required, as the final output directory path is |
| # not known yet. This much walk back the bazel_package path fragments. |
| generated.record_raw_symlink( |
| f"{bazel_package}/{build_dir_name}", |
| ("../" * len(bazel_package.split("/"))) + build_dir_name, |
| ) |
| |
| # The symlink for the special all_licenses_spdx.json file. |
| # IMPORTANT: This must end in `.spdx.json` for license classification to work correctly! |
| generated.record_symlink( |
| "all_licenses.spdx.json", all_licenses_spdx_path.resolve() |
| ) |
| |
| # The content of BUILD.bazel |
| build_content = """# AUTO-GENERATED - DO NOT EDIT |
| load("@rules_license//rules:license.bzl", "license") |
| |
| # This contains information about all the licenses of all |
| # Ninja outputs exposed in this repository. |
| # IMPORTANT: package_name *must* be "Legacy Ninja Build Outputs" |
| # as several license pipeline exception files hard-code this under //vendor/... |
| license( |
| name = "all_licenses_spdx_json", |
| package_name = "Legacy Ninja Build Outputs", |
| license_text = "all_licenses.spdx.json", |
| visibility = ["//visibility:public"] |
| ) |
| |
| """ |
| generated.record_file_content("BUILD.bazel", build_content) |
| generated.record_file_content( |
| "MODULE.bazel", |
| """# AUTO-GENERATED - DO NOT EDIT |
| |
| module(name = "gn_targets", version = "1") |
| |
| bazel_dep(name = "rules_license", version = "1.0.0")""", |
| ) |
| |
| |
| def check_regenerator_inputs_updates( |
| build_dir: Path, |
| inputs_file: str = "regenerator_outputs/regenerator_inputs.txt", |
| ) -> set[str]: |
| """Check whether any regenerator input has changed. |
| |
| Args: |
| build_dir: Ninja build directory path. |
| inputs_file: Optional path string to the file listing all inputs, |
| relative to build_dir. Default value is |
| regenerator_outputs/regenerator_inputs.txt. |
| |
| Returns: |
| A set of file paths, relative to build_dir, whose timestamp is |
| newer than that of the inputs_file file itself. |
| """ |
| inputs_path = build_dir / inputs_file |
| if not inputs_path.exists(): |
| # If the file does not exist, a regeneration is required. |
| return {inputs_file} |
| |
| inputs_timestamp = inputs_path.stat().st_mtime |
| changed_inputs: set[str] = set() |
| for dep in inputs_path.read_text().splitlines(): |
| dep_path = build_dir / dep |
| if not dep_path.exists(): |
| changed_inputs.add(dep) |
| continue |
| |
| dep_timestamp = dep_path.stat().st_mtime |
| if dep_timestamp > inputs_timestamp: |
| changed_inputs.add(dep) |
| |
| return changed_inputs |
| |
| |
| def repository_name(label: str) -> str: |
| """Returns repository name of the input label. |
| |
| Supports both canonical repository names (starts with @@) and apparent |
| repository names (starts with @). |
| """ |
| repository, sep, _ = label.partition("//") |
| assert sep == "//", f"Missing // in label: {label}" |
| return repository.removeprefix("@@").removeprefix("@") |
| |
| |
| def innermost_repository_name(label: str) -> str: |
| """Returns the innermost repository names. |
| |
| * For top-level repositories, this is their canonical repository name. |
| |
| * For repos generated by extensions, this is the repo_name used their |
| corresponding extensions' repo namespaces. Repo generated by extensions |
| have the canonical names in the form of |
| `module_repo_canonical_name+extension_name+repo_name`, this function |
| returns `repo_name` from it. |
| |
| See https://bazel.build/external/extension#repository_names_and_visibility |
| |
| NOTE: It's clearly stated in the doc that this naming convention is NOT an |
| API. However, in our use cases, we'd either do rely on this or hardcode |
| canonical names for extension generated repos everywhere. And the latter |
| is even less reliable. |
| """ |
| canonical_repo_name = repository_name(label) |
| if _BAZEL_REPO_NAME_SEPARATOR not in canonical_repo_name: |
| return canonical_repo_name |
| elements = canonical_repo_name.split(_BAZEL_REPO_NAME_SEPARATOR) |
| if len(elements) < 3: |
| # This is a repo_name+version canonical name, return as-is. |
| return canonical_repo_name |
| return elements[-1] |