Skip to content

Commit 1893b97

Browse files
[Nexthop] Add component system for FBOSS Image Builder
Add abstract build component framework for managing build operations. - Implement AbstractComponent base class for build components - Add component lifecycle management (prepare, build, extract) - Integrate with artifact store, download, and execute modules - Enable extensible component-based build architecture Tests utilizing the above infrastructure will be added when component build supports are included.
1 parent ebe0ce8 commit 1893b97

File tree

1 file changed

+343
-0
lines changed

1 file changed

+343
-0
lines changed
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)