Skip to content

Commit f61517d

Browse files
[Nexthop] Add build entrypoint for FBOSS Image Builder
Add build entrypoint orchestration for component-based builds. - Implement build entrypoint for coordinating component build workflows - Add support for build configuration and execution management - Include comprehensive unit tests for entrypoint functionality
1 parent e18c6e2 commit f61517d

File tree

2 files changed

+276
-0
lines changed

2 files changed

+276
-0
lines changed
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Build entry point for component builds inside the container.
4+
5+
This is the standard entry point for all component builds. It:
6+
1. Discovers dependencies mounted at /dependencies/
7+
2. Extracts tarballs if needed
8+
3. Installs RPMs from dependencies
9+
4. Executes the component build script
10+
11+
Usage:
12+
python3 build_entrypoint.py <build_script> [args...]
13+
14+
Example:
15+
python3 /workspace/fboss-image/distro_cli/lib/build_entrypoint.py /workspace/fboss-image/kernel/scripts/build.sh
16+
"""
17+
18+
import logging
19+
import subprocess
20+
import sys
21+
import tempfile
22+
from pathlib import Path
23+
24+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
25+
logger = logging.getLogger(__name__)
26+
27+
# Standard location where dependencies are mounted
28+
DEPENDENCIES_DIR = Path("/dependencies")
29+
30+
31+
def extract_tarball(tarball_path: Path, extract_dir: Path) -> None:
32+
"""Extract a tarball to the specified directory using tar command.
33+
34+
Args:
35+
tarball_path: Path to the tarball file
36+
extract_dir: Directory to extract to
37+
"""
38+
logger.info(f"Extracting {tarball_path} to {extract_dir}")
39+
try:
40+
# Use tar command for better compression support (zstd) and multithreading
41+
cmd = ["tar", "-xf", str(tarball_path), "-C", str(extract_dir)]
42+
logger.info(f"Running: {' '.join(cmd)}")
43+
subprocess.run(cmd, check=True)
44+
logger.info(f"Successfully extracted {tarball_path}")
45+
except subprocess.CalledProcessError as e:
46+
logger.error(f"Failed to extract {tarball_path}: {e}")
47+
raise
48+
except Exception as e:
49+
logger.error(f"Failed to extract {tarball_path}: {e}")
50+
raise
51+
52+
53+
def find_rpms(directory: Path) -> list[Path]:
54+
"""Find all RPM files in a directory (recursively).
55+
56+
Args:
57+
directory: Directory to search
58+
59+
Returns:
60+
List of paths to RPM files
61+
"""
62+
rpms = list(directory.rglob("*.rpm"))
63+
logger.info(f"Found {len(rpms)} RPM(s) in {directory}")
64+
return rpms
65+
66+
67+
def install_rpms(rpm_paths: list[Path]) -> None:
68+
"""Install RPMs using dnf.
69+
70+
Args:
71+
rpm_paths: List of paths to RPM files
72+
"""
73+
if not rpm_paths:
74+
logger.info("No RPMs to install")
75+
return
76+
77+
rpm_str_paths = [str(rpm) for rpm in rpm_paths]
78+
logger.info(
79+
f"Installing {len(rpm_paths)} RPM(s): {', '.join([rpm.name for rpm in rpm_paths])}"
80+
)
81+
82+
cmd = ["dnf", "install", "-y", *rpm_str_paths]
83+
logger.info(f"Running: {' '.join(cmd)}")
84+
subprocess.run(cmd, check=True, capture_output=False)
85+
logger.info("Successfully installed RPMs")
86+
87+
88+
def discover_dependencies() -> list[Path]:
89+
"""Discover all dependencies in the standard /dependencies directory.
90+
91+
Returns:
92+
List of paths to dependency artifacts (files or directories)
93+
"""
94+
if not DEPENDENCIES_DIR.exists():
95+
logger.info(f"No dependencies directory found at {DEPENDENCIES_DIR}")
96+
return []
97+
98+
dependencies = []
99+
for dep_path in DEPENDENCIES_DIR.iterdir():
100+
if dep_path.name.startswith("."):
101+
# Skip hidden files/directories
102+
continue
103+
dependencies.append(dep_path)
104+
logger.info(f"Discovered dependency: {dep_path.name}")
105+
106+
return dependencies
107+
108+
109+
def process_dependency(dep_path: Path, temp_dir: Path) -> None:
110+
"""Process a dependency: extract if tarball, then install RPMs.
111+
112+
Args:
113+
dep_path: Path to the dependency (file or directory)
114+
temp_dir: Temporary directory for extraction
115+
"""
116+
dep_name = dep_path.name
117+
logger.info(f"Processing dependency '{dep_name}' at {dep_path}")
118+
119+
if not dep_path.exists():
120+
logger.warning(f"Dependency path does not exist: {dep_path}")
121+
return
122+
123+
# Determine the directory to search for RPMs
124+
if dep_path.is_file():
125+
# It's a tarball - extract it
126+
extract_dir = temp_dir / dep_name
127+
extract_dir.mkdir(parents=True, exist_ok=True)
128+
extract_tarball(dep_path, extract_dir)
129+
search_dir = extract_dir
130+
else:
131+
# It's already a directory
132+
search_dir = dep_path
133+
134+
# Find and install RPMs
135+
rpms = find_rpms(search_dir)
136+
if rpms:
137+
install_rpms(rpms)
138+
else:
139+
logger.info(f"No RPMs found in dependency '{dep_name}'")
140+
141+
142+
def main():
143+
# Minimum required arguments: script name + build command
144+
min_args = 2
145+
if len(sys.argv) < min_args:
146+
logger.error("Usage: build_entrypoint.py <build_script> [args...]")
147+
logger.error(
148+
"Example: build_entrypoint.py /workspace/fboss-image/kernel/scripts/build.sh"
149+
)
150+
sys.exit(1)
151+
152+
# Build command is everything after the script name
153+
build_command = sys.argv[1:]
154+
155+
logger.info("=" * 60)
156+
logger.info("Dependency Installation and Build Entry Point")
157+
logger.info("=" * 60)
158+
159+
# Create temporary directory for extractions
160+
with tempfile.TemporaryDirectory(prefix="dep-install-") as temp_dir:
161+
temp_path = Path(temp_dir)
162+
163+
# Discover and process all dependencies
164+
dependencies = discover_dependencies()
165+
if dependencies:
166+
logger.info(f"Found {len(dependencies)} dependency/dependencies")
167+
for dep_path in dependencies:
168+
try:
169+
process_dependency(dep_path, temp_path)
170+
except Exception as e:
171+
logger.error(f"Failed to process dependency '{dep_path.name}': {e}")
172+
sys.exit(1)
173+
else:
174+
logger.info("No dependencies found - proceeding with build")
175+
176+
# Execute the build command
177+
logger.info("=" * 60)
178+
logger.info(f"Executing build: {' '.join(build_command)}")
179+
logger.info("=" * 60)
180+
try:
181+
result = subprocess.run(build_command, check=False)
182+
sys.exit(result.returncode)
183+
except Exception as e:
184+
logger.error(f"Failed to execute build command: {e}")
185+
sys.exit(1)
186+
187+
188+
if __name__ == "__main__":
189+
main()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
"""Test build_entrypoint.py behavior."""
9+
10+
import unittest
11+
from pathlib import Path
12+
13+
from distro_cli.lib.constants import FBOSS_BUILDER_IMAGE
14+
from distro_cli.lib.docker.container import run_container
15+
from distro_cli.lib.paths import get_root_dir
16+
from distro_cli.tests.test_helpers import ensure_test_docker_image, enter_tempdir
17+
18+
19+
class TestBuildEntrypoint(unittest.TestCase):
20+
"""Test build_entrypoint.py as universal build entry point."""
21+
22+
@classmethod
23+
def setUpClass(cls):
24+
"""Ensure fboss_builder image exists before running tests."""
25+
ensure_test_docker_image()
26+
27+
def test_entrypoint_without_dependencies(self):
28+
"""Test build_entrypoint.py executes build command when no dependencies exist."""
29+
with enter_tempdir("entrypoint_no_deps_") as tmpdir_path:
30+
output_file = tmpdir_path / "build_output.txt"
31+
32+
# Mount workspace (contains build_entrypoint.py)
33+
# No /dependencies mount - simulates build without dependencies
34+
exit_code = run_container(
35+
image=FBOSS_BUILDER_IMAGE,
36+
command=[
37+
"python3",
38+
"/workspace/fboss-image/distro_cli/lib/build_entrypoint.py",
39+
"sh",
40+
"-c",
41+
"echo 'build completed' > /output/build_output.txt",
42+
],
43+
volumes={
44+
get_root_dir(): Path("/workspace"), # Mount repo root
45+
tmpdir_path: Path("/output"),
46+
},
47+
ephemeral=True,
48+
)
49+
50+
self.assertEqual(exit_code, 0, "Build should succeed without dependencies")
51+
self.assertTrue(output_file.exists(), "Build output should be created")
52+
self.assertEqual(output_file.read_text().strip(), "build completed")
53+
54+
def test_entrypoint_with_empty_dependencies(self):
55+
"""Test build_entrypoint.py handles empty /dependencies directory gracefully."""
56+
with enter_tempdir("entrypoint_empty_deps_") as tmpdir_path:
57+
output_file = tmpdir_path / "build_output.txt"
58+
deps_dir = tmpdir_path / "deps"
59+
deps_dir.mkdir(exist_ok=True)
60+
61+
# Mount empty /dependencies directory
62+
exit_code = run_container(
63+
image=FBOSS_BUILDER_IMAGE,
64+
command=[
65+
"python3",
66+
"/workspace/fboss-image/distro_cli/lib/build_entrypoint.py",
67+
"sh",
68+
"-c",
69+
"echo 'build with empty deps' > /output/build_output.txt",
70+
],
71+
volumes={
72+
get_root_dir(): Path("/workspace"),
73+
tmpdir_path: Path("/output"),
74+
deps_dir: Path("/dependencies"),
75+
},
76+
ephemeral=True,
77+
)
78+
79+
self.assertEqual(
80+
exit_code, 0, "Build should succeed with empty dependencies"
81+
)
82+
self.assertTrue(output_file.exists())
83+
self.assertEqual(output_file.read_text().strip(), "build with empty deps")
84+
85+
86+
if __name__ == "__main__":
87+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)