Skip to content

Commit e18c6e2

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 7a68ef7 commit e18c6e2

File tree

1 file changed

+274
-0
lines changed

1 file changed

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

Comments
 (0)