|
| 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 os.path import commonpath |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +from distro_cli.lib.artifact import find_artifact_in_dir |
| 16 | +from distro_cli.lib.constants import FBOSS_BUILDER_IMAGE |
| 17 | +from distro_cli.lib.download import download_artifact |
| 18 | +from distro_cli.lib.exceptions import ComponentError |
| 19 | +from distro_cli.lib.execute import execute_build_in_container |
| 20 | +from distro_cli.lib.paths import get_abs_path |
| 21 | + |
| 22 | +logger = logging.getLogger(__name__) |
| 23 | + |
| 24 | + |
| 25 | +def _get_component_directory(component_name: str, script_path: str) -> str: |
| 26 | + """Determine the component directory for build artifacts. |
| 27 | +
|
| 28 | + For scripts_path that has the component_name, we return the path in script_path |
| 29 | + leading to the component_name. Otherwise, the script's parent directory is returned. |
| 30 | +
|
| 31 | + Examples: |
| 32 | + kernel component: |
| 33 | + component_name="kernel" |
| 34 | + script_path="fboss-image/kernel/scripts/build_kernel.sh" |
| 35 | + returns: "fboss-image/kernel" (component_name found in path) |
| 36 | +
|
| 37 | + sai component: |
| 38 | + component_name="sai" |
| 39 | + script_path="broadcom-sai-sdk/build_fboss_sai.sh" |
| 40 | + returns: "broadcom-sai-sdk" (fallback to script's parent) |
| 41 | +
|
| 42 | + Args: |
| 43 | + component_name: Base component name (without array index) |
| 44 | + script_path: Path to the build script from the execute directive |
| 45 | +
|
| 46 | + Returns: |
| 47 | + Component directory path (relative to workspace root) |
| 48 | +
|
| 49 | + """ |
| 50 | + script_path_obj = Path(script_path) |
| 51 | + |
| 52 | + # Check if component_name appears in the script path |
| 53 | + if component_name in script_path_obj.parts: |
| 54 | + # Find the last occurrence of component_name in the path |
| 55 | + # Handle cases where component name appears multiple times |
| 56 | + # e.g., /src/kernel/fboss/12345/kernel/build.sh -> use the last "kernel" |
| 57 | + parts = script_path_obj.parts |
| 58 | + # Find last occurrence by reversing and using index |
| 59 | + component_index = len(parts) - 1 - parts[::-1].index(component_name) |
| 60 | + # Return the path up to and including the component_name |
| 61 | + return str(Path(*parts[: component_index + 1])) |
| 62 | + |
| 63 | + # Fall back to script's parent directory |
| 64 | + return str(script_path_obj.parent) |
| 65 | + |
| 66 | + |
| 67 | +class ComponentBuilder: |
| 68 | + """Generic builder for FBOSS image components. |
| 69 | +
|
| 70 | + Supports two modes: |
| 71 | + - download: Download pre-built artifact from URL |
| 72 | + - execute: Build component using a build script in Docker container |
| 73 | +
|
| 74 | + Component-specific logic (argument parsing, paths, etc.) should be |
| 75 | + embedded in the component build script, not in this builder. |
| 76 | + """ |
| 77 | + |
| 78 | + def __init__( |
| 79 | + self, |
| 80 | + component_name: str, |
| 81 | + component_data: dict, |
| 82 | + manifest_dir: Path, |
| 83 | + store, |
| 84 | + artifact_pattern: str | None = None, |
| 85 | + dependency_artifacts: dict[str, Path] | None = None, |
| 86 | + artifact_key_salt: str | None = None, |
| 87 | + ): |
| 88 | + """Initialize the component builder. |
| 89 | +
|
| 90 | + Args: |
| 91 | + component_name: Name of the component |
| 92 | + component_data: Component data dict from manifest |
| 93 | + manifest_dir: Path to the manifest directory |
| 94 | + store: ArtifactStore instance |
| 95 | + artifact_pattern: Glob pattern for finding build artifacts (e.g., "kernel-*.rpms.tar.zst") |
| 96 | + If None, component cannot use execute mode |
| 97 | + dependency_artifacts: Optional dict mapping dependency names to their artifact paths |
| 98 | + artifact_key_salt: Salt added to artifact store key to differentiate variants |
| 99 | + """ |
| 100 | + self.component_name = component_name |
| 101 | + self.component_data = component_data |
| 102 | + self.manifest_dir = manifest_dir |
| 103 | + self.store = store |
| 104 | + self.artifact_pattern = artifact_pattern |
| 105 | + self.dependency_artifacts = dependency_artifacts or {} |
| 106 | + self.artifact_key_salt = artifact_key_salt |
| 107 | + |
| 108 | + def build(self) -> Path: |
| 109 | + """Build or download the component. |
| 110 | +
|
| 111 | + Returns: |
| 112 | + Path to the component artifact (or None for empty components) |
| 113 | +
|
| 114 | + Raises: |
| 115 | + ComponentError: If component has invalid structure |
| 116 | + """ |
| 117 | + if self.component_data is None: |
| 118 | + raise ComponentError(f"Component '{self.component_name}' has no data") |
| 119 | + |
| 120 | + # ComponentBuilder handles single component instances only |
| 121 | + # Array components should be handled at a higher level (e.g., ImageBuilder) |
| 122 | + # by creating one ComponentBuilder instance per array element |
| 123 | + if isinstance(self.component_data, list): |
| 124 | + raise ComponentError( |
| 125 | + f"Component '{self.component_name}' data is an array. " |
| 126 | + "ComponentBuilder only handles single component instances. " |
| 127 | + "Create one ComponentBuilder per array element instead." |
| 128 | + ) |
| 129 | + |
| 130 | + # Check for both download and execute (invalid) |
| 131 | + has_download = "download" in self.component_data |
| 132 | + has_execute = "execute" in self.component_data |
| 133 | + |
| 134 | + if has_download and has_execute: |
| 135 | + raise ComponentError( |
| 136 | + f"Component '{self.component_name}' has both 'download' and 'execute' fields. " |
| 137 | + "Only one is allowed." |
| 138 | + ) |
| 139 | + |
| 140 | + # Allow empty components |
| 141 | + if not has_download and not has_execute: |
| 142 | + logger.info(f"Component '{self.component_name}' is empty, skipping") |
| 143 | + return None |
| 144 | + |
| 145 | + if has_download: |
| 146 | + return self._download_component(self.component_data["download"]) |
| 147 | + |
| 148 | + # this must be an "execute" |
| 149 | + # Use artifact pattern from manifest if specified, otherwise use default |
| 150 | + artifact_pattern = self.component_data.get("artifact", self.artifact_pattern) |
| 151 | + return self._execute_component(self.component_data["execute"], artifact_pattern) |
| 152 | + |
| 153 | + def _download_component(self, url: str) -> Path: |
| 154 | + """Download component artifact from URL. |
| 155 | +
|
| 156 | + Args: |
| 157 | + url: URL to download from |
| 158 | +
|
| 159 | + Returns: |
| 160 | + Path to downloaded artifact (cached) |
| 161 | + """ |
| 162 | + store_key = ( |
| 163 | + f"{self.component_name}-download-{hashlib.sha256(url.encode()).hexdigest()}" |
| 164 | + ) |
| 165 | + data_files, metadata_files = self.store.get( |
| 166 | + store_key, |
| 167 | + lambda data, meta: download_artifact(url, self.manifest_dir, data, meta), |
| 168 | + ) |
| 169 | + |
| 170 | + if not data_files: |
| 171 | + raise ComponentError(f"No artifact files found for {self.component_name}") |
| 172 | + |
| 173 | + artifact_path = data_files[0] |
| 174 | + logger.info(f"{self.component_name} artifact ready: {artifact_path}") |
| 175 | + if metadata_files: |
| 176 | + logger.debug(f" with {len(metadata_files)} metadata file(s)") |
| 177 | + return artifact_path |
| 178 | + |
| 179 | + def _execute_component( |
| 180 | + self, cmd_line: str | list[str], artifact_pattern: str | None = None |
| 181 | + ) -> Path: |
| 182 | + """Execute component build in Docker container. |
| 183 | +
|
| 184 | + Args: |
| 185 | + cmd_line: Command line to execute (string or list of strings) |
| 186 | + artifact_pattern: Optional artifact pattern override from manifest |
| 187 | +
|
| 188 | + Returns: |
| 189 | + Path to build artifact in cache |
| 190 | + """ |
| 191 | + # For store key, convert to string (works for both str and list) |
| 192 | + # Include artifact_key_salt to differentiate cache variants (e.g., compressed vs uncompressed) |
| 193 | + store_key_str = f"{cmd_line}-salt={self.artifact_key_salt}" |
| 194 | + |
| 195 | + # _execute_build as a fetch_fn always starts a build expecting the underlying |
| 196 | + # build system to provide build specific optimizations. The objects are returned |
| 197 | + # back to the store with a store-miss indication. |
| 198 | + store_key = f"{self.component_name}-build-{hashlib.sha256(store_key_str.encode()).hexdigest()[:8]}" |
| 199 | + data_files, _ = self.store.get( |
| 200 | + store_key, |
| 201 | + lambda _data, _meta: ( |
| 202 | + False, |
| 203 | + [self._execute_build(cmd_line, artifact_pattern)], |
| 204 | + [], |
| 205 | + ), |
| 206 | + ) |
| 207 | + |
| 208 | + if not data_files: |
| 209 | + raise ComponentError(f"No artifact files found for {self.component_name}") |
| 210 | + |
| 211 | + artifact_path = data_files[0] |
| 212 | + logger.info(f"{self.component_name} build complete: {artifact_path}") |
| 213 | + return artifact_path |
| 214 | + |
| 215 | + def _execute_build( |
| 216 | + self, cmd_line: str | list[str], artifact_pattern: str | None = None |
| 217 | + ) -> Path: |
| 218 | + """Execute build in Docker container. |
| 219 | +
|
| 220 | + Args: |
| 221 | + cmd_line: Command line to execute (string or list of strings) |
| 222 | + artifact_pattern: Optional artifact pattern override from manifest |
| 223 | +
|
| 224 | + Returns: |
| 225 | + Path to build artifact |
| 226 | +
|
| 227 | + Raises: |
| 228 | + ComponentError: If build fails or artifact not found |
| 229 | + """ |
| 230 | + # Get script path from command line |
| 231 | + script_path_str = ( |
| 232 | + cmd_line[0] if isinstance(cmd_line, list) else cmd_line.split()[0] |
| 233 | + ) |
| 234 | + |
| 235 | + # Resolve script path: if absolute, use as-is; if relative, resolve from manifest_dir |
| 236 | + script_path = Path(script_path_str) |
| 237 | + resolved_script_path = ( |
| 238 | + script_path |
| 239 | + if script_path.is_absolute() |
| 240 | + else (self.manifest_dir / script_path).resolve() |
| 241 | + ) |
| 242 | + |
| 243 | + # Verify the script exists |
| 244 | + if not resolved_script_path.exists(): |
| 245 | + raise ComponentError( |
| 246 | + f"Build script not found: {resolved_script_path} " |
| 247 | + f"(from manifest path: {script_path_str})" |
| 248 | + ) |
| 249 | + |
| 250 | + # We mount the common parent of the script path and manifest dir |
| 251 | + src_dir = Path(commonpath([resolved_script_path, self.manifest_dir])) |
| 252 | + script_relative_to_src = resolved_script_path.relative_to(src_dir) |
| 253 | + container_script_path = Path("/src") / script_relative_to_src |
| 254 | + |
| 255 | + # For array elements, extract the base name |
| 256 | + base_name = ( |
| 257 | + self.component_name.split("[")[0] |
| 258 | + if "[" in self.component_name |
| 259 | + else self.component_name |
| 260 | + ) |
| 261 | + |
| 262 | + # Determine component directory (component root if known, else script's parent) |
| 263 | + # Use the resolved script path relative to src_dir |
| 264 | + script_relative_to_src = resolved_script_path.relative_to(src_dir) |
| 265 | + component_dir = _get_component_directory(base_name, str(script_relative_to_src)) |
| 266 | + |
| 267 | + # Create build and dist directories under the component directory |
| 268 | + artifact_base_dir = src_dir / component_dir |
| 269 | + |
| 270 | + # Use artifact_pattern from parameter, or fall back to instance pattern, or use generic pattern |
| 271 | + # Generic pattern uses .tar (will match both .tar and .tar.zst via compression variant finder) |
| 272 | + if artifact_pattern is None: |
| 273 | + artifact_pattern = self.artifact_pattern or "*.tar" |
| 274 | + if not artifact_pattern: |
| 275 | + logger.warning( |
| 276 | + f"Component '{self.component_name}' has no artifact_pattern specified. " |
| 277 | + f"Using generic pattern: {artifact_pattern}" |
| 278 | + ) |
| 279 | + |
| 280 | + build_dir = artifact_base_dir / ".build" |
| 281 | + build_dir.mkdir(parents=True, exist_ok=True) |
| 282 | + |
| 283 | + dist_dir = artifact_base_dir / "dist" |
| 284 | + dist_dir.mkdir(parents=True, exist_ok=True) |
| 285 | + |
| 286 | + # Mount src_dir as /src in the container |
| 287 | + logger.info(f"Mounting {src_dir} as /src") |
| 288 | + |
| 289 | + # Mount distro_cli/tools as /tools for build utilities |
| 290 | + tools_dir = get_abs_path("fboss-image/distro_cli/tools") |
| 291 | + |
| 292 | + # Mount fboss/oss/scripts for common build utilities (sccache config, etc.) |
| 293 | + common_scripts_dir = get_abs_path("fboss/oss/scripts") |
| 294 | + |
| 295 | + volumes = { |
| 296 | + src_dir: Path("/src"), |
| 297 | + build_dir: Path("/build"), |
| 298 | + dist_dir: Path("/output"), |
| 299 | + tools_dir: Path("/tools"), |
| 300 | + common_scripts_dir: Path("/fboss/oss/scripts"), |
| 301 | + } |
| 302 | + |
| 303 | + # Mount dependency artifacts into the container |
| 304 | + dependency_install_paths = {} |
| 305 | + for dep_name, dep_artifact in self.dependency_artifacts.items(): |
| 306 | + dep_mount_point = Path(f"/deps/{dep_name}") |
| 307 | + volumes[dep_artifact] = dep_mount_point |
| 308 | + dependency_install_paths[dep_name] = dep_mount_point |
| 309 | + logger.info( |
| 310 | + f"Mounting dependency '{dep_name}' at {dep_mount_point}: {dep_artifact}" |
| 311 | + ) |
| 312 | + |
| 313 | + # Working directory is the parent of the script |
| 314 | + working_dir = str(container_script_path.parent) |
| 315 | + |
| 316 | + # Build the container command using the container script path |
| 317 | + if isinstance(cmd_line, list): |
| 318 | + # Replace first element with container path, keep the rest |
| 319 | + container_cmd = [str(container_script_path), *cmd_line[1:]] |
| 320 | + else: |
| 321 | + # For string commands, replace the script path with the in-container version |
| 322 | + container_cmd = [str(container_script_path)] |
| 323 | + |
| 324 | + logger.info(f"Container command: {container_cmd}") |
| 325 | + |
| 326 | + # Execute build command |
| 327 | + execute_build_in_container( |
| 328 | + image_name=FBOSS_BUILDER_IMAGE, |
| 329 | + command=container_cmd, |
| 330 | + volumes=volumes, |
| 331 | + component_name=self.component_name, |
| 332 | + privileged=False, |
| 333 | + working_dir=working_dir, |
| 334 | + dependency_install_paths=( |
| 335 | + dependency_install_paths if dependency_install_paths else None |
| 336 | + ), |
| 337 | + ) |
| 338 | + |
| 339 | + return find_artifact_in_dir( |
| 340 | + output_dir=dist_dir, |
| 341 | + pattern=artifact_pattern, |
| 342 | + component_name=self.component_name.capitalize(), |
| 343 | + ) |
0 commit comments