|
| 1 | +# Copyright (c) 2004-present, Facebook, Inc. |
| 2 | +# All rights reserved. |
| 3 | +# |
| 4 | +# This source code is licensed under the BSD-style license found in the |
| 5 | +# LICENSE file in the root directory of this source tree. An additional grant |
| 6 | +# of patent rights can be found in the PATENTS file in the same directory. |
| 7 | + |
| 8 | +"""Generic component builder for FBOSS image components.""" |
| 9 | + |
| 10 | +import hashlib |
| 11 | +import logging |
| 12 | +from pathlib import Path |
| 13 | + |
| 14 | +from distro_cli.lib.artifact import find_artifact_in_dir |
| 15 | +from distro_cli.lib.constants import FBOSS_BUILDER_IMAGE |
| 16 | +from distro_cli.lib.download import download_artifact |
| 17 | +from distro_cli.lib.exceptions import ComponentError |
| 18 | +from distro_cli.lib.execute import execute_build_in_container |
| 19 | + |
| 20 | +logger = logging.getLogger(__name__) |
| 21 | + |
| 22 | + |
| 23 | +class ComponentBuilder: |
| 24 | + """Generic builder for FBOSS image components. |
| 25 | +
|
| 26 | + Supports two modes: |
| 27 | + - download: Download pre-built artifact from URL |
| 28 | + - execute: Build component using a build script in Docker container |
| 29 | +
|
| 30 | + Component-specific logic (argument parsing, paths, etc.) should be |
| 31 | + embedded in the component build script, not in this builder. |
| 32 | + """ |
| 33 | + |
| 34 | + def __init__( |
| 35 | + self, |
| 36 | + component_name: str, |
| 37 | + component_data: dict, |
| 38 | + manifest_dir: Path, |
| 39 | + store, |
| 40 | + root_dir: Path | None = None, |
| 41 | + build_artifact_subdir: str | None = None, |
| 42 | + artifact_pattern: str | None = None, |
| 43 | + dependency_artifacts: dict[str, Path] | None = None, |
| 44 | + ): |
| 45 | + """Initialize the component builder. |
| 46 | +
|
| 47 | + Args: |
| 48 | + component_name: Name of the component |
| 49 | + component_data: Component data dict from manifest |
| 50 | + manifest_dir: Path to the manifest directory |
| 51 | + store: ArtifactStore instance |
| 52 | + root_dir: Path to the root directory (workspace root). |
| 53 | + If None, component cannot use execute mode |
| 54 | + build_artifact_subdir: Subpath under root_dir where .build and dist directories |
| 55 | + will be created (e.g., "fboss-image/kernel"). |
| 56 | + If None, component cannot use execute mode |
| 57 | + artifact_pattern: Glob pattern for finding build artifacts (e.g., "kernel-*.rpms.tar.gz") |
| 58 | + If None, component cannot use execute mode |
| 59 | + dependency_artifacts: Optional dict mapping dependency names to their artifact paths |
| 60 | + """ |
| 61 | + self.component_name = component_name |
| 62 | + self.component_data = component_data |
| 63 | + self.manifest_dir = manifest_dir |
| 64 | + self.store = store |
| 65 | + self.root_dir = root_dir |
| 66 | + self.build_artifact_subdir = build_artifact_subdir |
| 67 | + self.artifact_pattern = artifact_pattern |
| 68 | + self.dependency_artifacts = dependency_artifacts or {} |
| 69 | + |
| 70 | + def build(self) -> Path: |
| 71 | + """Build or download the component. |
| 72 | +
|
| 73 | + Returns: |
| 74 | + Path to the component artifact (or None for empty components) |
| 75 | +
|
| 76 | + Raises: |
| 77 | + ComponentError: If component has invalid structure |
| 78 | + """ |
| 79 | + if self.component_data is None: |
| 80 | + raise ComponentError(f"Component '{self.component_name}' has no data") |
| 81 | + |
| 82 | + # ComponentBuilder handles single component instances only |
| 83 | + # Array components should be handled at a higher level (e.g., ImageBuilder) |
| 84 | + # by creating one ComponentBuilder instance per array element |
| 85 | + if isinstance(self.component_data, list): |
| 86 | + raise ComponentError( |
| 87 | + f"Component '{self.component_name}' data is an array. " |
| 88 | + "ComponentBuilder only handles single component instances. " |
| 89 | + "Create one ComponentBuilder per array element instead." |
| 90 | + ) |
| 91 | + |
| 92 | + # Check for both download and execute (invalid) |
| 93 | + has_download = "download" in self.component_data |
| 94 | + has_execute = "execute" in self.component_data |
| 95 | + |
| 96 | + if has_download and has_execute: |
| 97 | + raise ComponentError( |
| 98 | + f"Component '{self.component_name}' has both 'download' and 'execute' fields. " |
| 99 | + "Only one is allowed." |
| 100 | + ) |
| 101 | + |
| 102 | + # Allow empty components |
| 103 | + if not has_download and not has_execute: |
| 104 | + logger.info(f"Component '{self.component_name}' is empty, skipping") |
| 105 | + return None |
| 106 | + |
| 107 | + if has_download: |
| 108 | + return self._download_component(self.component_data["download"]) |
| 109 | + |
| 110 | + # this must be an "execute" |
| 111 | + # Use artifact pattern from manifest if specified, otherwise use default |
| 112 | + artifact_pattern = self.component_data.get("artifact", self.artifact_pattern) |
| 113 | + return self._execute_component(self.component_data["execute"], artifact_pattern) |
| 114 | + |
| 115 | + def _download_component(self, url: str) -> Path: |
| 116 | + """Download component artifact from URL. |
| 117 | +
|
| 118 | + Args: |
| 119 | + url: URL to download from |
| 120 | +
|
| 121 | + Returns: |
| 122 | + Path to downloaded artifact (cached) |
| 123 | + """ |
| 124 | + store_key = ( |
| 125 | + f"{self.component_name}-download-{hashlib.sha256(url.encode()).hexdigest()}" |
| 126 | + ) |
| 127 | + data_files, metadata_files = self.store.get( |
| 128 | + store_key, |
| 129 | + lambda data, meta: download_artifact(url, self.manifest_dir, data, meta), |
| 130 | + ) |
| 131 | + |
| 132 | + if not data_files: |
| 133 | + raise ComponentError(f"No artifact files found for {self.component_name}") |
| 134 | + |
| 135 | + if len(data_files) != 1: |
| 136 | + raise ComponentError( |
| 137 | + f"Expected exactly 1 data file for {self.component_name}, got {len(data_files)}" |
| 138 | + ) |
| 139 | + |
| 140 | + artifact_path = data_files[0] |
| 141 | + logger.info(f"{self.component_name} artifact ready: {artifact_path}") |
| 142 | + if metadata_files: |
| 143 | + logger.debug(f" with {len(metadata_files)} metadata file(s)") |
| 144 | + return artifact_path |
| 145 | + |
| 146 | + def _execute_component( |
| 147 | + self, cmd_line: str | list[str], artifact_pattern: str | None = None |
| 148 | + ) -> Path: |
| 149 | + """Execute component build in Docker container. |
| 150 | +
|
| 151 | + Args: |
| 152 | + cmd_line: Command line to execute (string or list of strings) |
| 153 | + artifact_pattern: Optional artifact pattern override from manifest |
| 154 | +
|
| 155 | + Returns: |
| 156 | + Path to build artifact in cache |
| 157 | + """ |
| 158 | + # For store key, convert to string (works for both str and list) |
| 159 | + store_key_str = str(cmd_line) |
| 160 | + |
| 161 | + # _execute_build as a fetch_fn always starts a build expecting the underlying |
| 162 | + # build system to provide build specific optimizations. The objects are returned |
| 163 | + # back to the store with a store-miss indication. |
| 164 | + store_key = f"{self.component_name}-build-{hashlib.sha256(store_key_str.encode()).hexdigest()[:8]}" |
| 165 | + data_files, _ = self.store.get( |
| 166 | + store_key, |
| 167 | + lambda _data, _meta: ( |
| 168 | + False, |
| 169 | + [self._execute_build(cmd_line, artifact_pattern)], |
| 170 | + [], |
| 171 | + ), |
| 172 | + ) |
| 173 | + |
| 174 | + if not data_files: |
| 175 | + raise ComponentError(f"No artifact files found for {self.component_name}") |
| 176 | + |
| 177 | + if len(data_files) != 1: |
| 178 | + raise ComponentError( |
| 179 | + f"Expected exactly 1 data file for {self.component_name}, got {len(data_files)}" |
| 180 | + ) |
| 181 | + |
| 182 | + artifact_path = data_files[0] |
| 183 | + logger.info(f"{self.component_name} build complete: {artifact_path}") |
| 184 | + return artifact_path |
| 185 | + |
| 186 | + def _execute_build( |
| 187 | + self, cmd_line: str | list[str], artifact_pattern: str | None = None |
| 188 | + ) -> Path: |
| 189 | + """Execute build in Docker container. |
| 190 | +
|
| 191 | + Args: |
| 192 | + cmd_line: Command line to execute (string or list of strings) |
| 193 | + artifact_pattern: Optional artifact pattern override from manifest |
| 194 | +
|
| 195 | + Returns: |
| 196 | + Path to build artifact |
| 197 | +
|
| 198 | + Raises: |
| 199 | + ComponentError: If build fails or artifact not found |
| 200 | + """ |
| 201 | + if not self.root_dir: |
| 202 | + raise ComponentError( |
| 203 | + f"Component '{self.component_name}' cannot use execute mode. " |
| 204 | + "No root_dir specified." |
| 205 | + ) |
| 206 | + |
| 207 | + # Create build and dist directories under build_artifact_subdir |
| 208 | + if self.build_artifact_subdir: |
| 209 | + artifact_base_dir = self.root_dir / self.build_artifact_subdir |
| 210 | + else: |
| 211 | + artifact_base_dir = self.root_dir |
| 212 | + logger.warning( |
| 213 | + f"Component '{self.component_name}' has no build_artifact_subdir specified. " |
| 214 | + f"Using root directory: {artifact_base_dir}" |
| 215 | + ) |
| 216 | + |
| 217 | + # Use artifact_pattern from parameter, or fall back to instance pattern, or use generic pattern |
| 218 | + if artifact_pattern is None: |
| 219 | + artifact_pattern = self.artifact_pattern or "*.tar.gz" |
| 220 | + if not artifact_pattern: |
| 221 | + logger.warning( |
| 222 | + f"Component '{self.component_name}' has no artifact_pattern specified. " |
| 223 | + f"Using generic pattern: {artifact_pattern}" |
| 224 | + ) |
| 225 | + |
| 226 | + build_dir = artifact_base_dir / ".build" |
| 227 | + build_dir.mkdir(parents=True, exist_ok=True) |
| 228 | + |
| 229 | + dist_dir = artifact_base_dir / "dist" |
| 230 | + dist_dir.mkdir(parents=True, exist_ok=True) |
| 231 | + |
| 232 | + volumes = { |
| 233 | + self.root_dir: Path("/workspace"), |
| 234 | + build_dir: Path("/build"), |
| 235 | + dist_dir: Path("/output"), |
| 236 | + } |
| 237 | + |
| 238 | + # Mount dependency artifacts into the container |
| 239 | + # Each dependency is mounted at /dependencies/{dep_name}/ |
| 240 | + # The build_entrypoint.py will handle extraction and RPM installation |
| 241 | + dependency_install_paths = {} |
| 242 | + for dep_name, dep_artifact in self.dependency_artifacts.items(): |
| 243 | + dep_mount_point = Path(f"/dependencies/{dep_name}") |
| 244 | + volumes[dep_artifact] = dep_mount_point |
| 245 | + dependency_install_paths[dep_name] = dep_mount_point |
| 246 | + logger.info( |
| 247 | + f"Mounting dependency '{dep_name}' at {dep_mount_point}: {dep_artifact}" |
| 248 | + ) |
| 249 | + |
| 250 | + # Working directory is always /workspace (root) |
| 251 | + # Execute command paths are relative to /workspace |
| 252 | + working_dir = "/workspace" |
| 253 | + |
| 254 | + # Normalize command to list (handle both string and list from manifest) |
| 255 | + cmd_list = [cmd_line] if isinstance(cmd_line, str) else cmd_line |
| 256 | + |
| 257 | + # Execute build command |
| 258 | + execute_build_in_container( |
| 259 | + image_name=FBOSS_BUILDER_IMAGE, |
| 260 | + command=cmd_list, |
| 261 | + volumes=volumes, |
| 262 | + component_name=self.component_name, |
| 263 | + privileged=False, |
| 264 | + working_dir=working_dir, |
| 265 | + dependency_install_paths=( |
| 266 | + dependency_install_paths if dependency_install_paths else None |
| 267 | + ), |
| 268 | + ) |
| 269 | + |
| 270 | + return find_artifact_in_dir( |
| 271 | + output_dir=dist_dir, |
| 272 | + pattern=artifact_pattern, |
| 273 | + component_name=self.component_name.capitalize(), |
| 274 | + ) |
0 commit comments