Skip to content

Commit 74f95cb

Browse files
committed
Better logging and build binary if not exist in cache #1938
* Build the package binary from source using Docker if binary cannot be found in cache.nixos.org * Enhance logging Signed-off-by: Chin Yeung Li <tli@nexb.com>
1 parent ee797a7 commit 74f95cb

3 files changed

Lines changed: 233 additions & 45 deletions

File tree

scanpipe/pipelines/scan_nix_package.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,20 +80,25 @@ def fetch_inputs(self):
8080
from_file = ""
8181
to_file = ""
8282
output_format = ""
83-
from_file, to_file, output_format = fetch_inputs(
84-
self.purl, self.project.codebase_path
83+
from_file, to_file, output_format, error_messages, warning_messages = (
84+
fetch_inputs(self.purl, self.project.codebase_path)
8585
)
8686
self.from_file = from_file
8787
self.to_file = to_file
8888
self.output_format = output_format
8989

9090
self.d2d_enable = bool(self.from_file and self.to_file)
9191

92+
if error_messages:
93+
self.project.add_error(error_messages)
94+
if warning_messages:
95+
self.project.add_warning(warning_messages)
96+
9297
def collect_input_info(self):
9398
"""Collect information about the input."""
9499
self.input_path = ""
95100
if self.to_file:
96-
self.input_path = self.to_file
101+
self.input_path = Path(self.to_file)
97102
self.collect_input_information()
98103

99104
def extract_input_to_codebase_directory(self):

scanpipe/pipes/nix.py

Lines changed: 126 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
# Visit https://github.com/aboutcode-org/scancode.io for support and download.
2222

2323
import atexit
24-
import concurrent.futures
2524
import logging
2625
import shutil
2726
import subprocess
@@ -71,7 +70,8 @@ def fetch_inputs(purl, output_dir):
7170
"""
7271
Fetch the system specific binary and the exact source tree with the
7372
patches and configurations applied for the given input purl. Return a
74-
tuple of (source_path, binary_path, output_format).
73+
tuple of (source_path, binary_path, output_format, error_message,
74+
warning_message).
7575
"""
7676
data = get_package_data(purl)
7777
name = purl.name
@@ -80,49 +80,140 @@ def fetch_inputs(purl, output_dir):
8080
commit_hash = purl.qualifiers.get("commit", "")
8181
system = purl.qualifiers.get("system", "")
8282
user_output = purl.qualifiers.get("output", "")
83+
error_message = ""
84+
warning_message = ""
8385

8486
output_format, path, release_commit_hash = get_nix_store_path(
8587
data, name, version, system, commit_hash, user_output
8688
)
8789

88-
nix_bin_download_url = get_nix_download_url(path) if path else ""
8990
concluded_commit_hash = release_commit_hash or commit_hash
9091

91-
src_path = ""
9292
bin_path = ""
93+
nix_bin_download_url = get_nix_download_url(path) if path else ""
94+
# Try to download from cache first
95+
if nix_bin_download_url:
96+
bin_path = utils.fetch_path(nix_bin_download_url)
9397

94-
# Run the Docker source patching and the Binary download concurrently
95-
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
96-
futures = {}
98+
if bin_path:
99+
logger.info(f"Downloaded binary for {purl} to {bin_path}")
100+
else:
97101
if concluded_commit_hash:
98-
futures["source"] = executor.submit(
99-
get_patched_source_with_docker,
100-
name,
101-
output_dir,
102-
system,
103-
concluded_commit_hash,
102+
logger.info(
103+
f"Binary not found in cache for {purl}. Attempting local Nix build..."
104+
)
105+
bin_path = build_binary_with_docker(
106+
name, output_dir, system, concluded_commit_hash, output_format
104107
)
105-
if nix_bin_download_url:
106-
futures["binary"] = executor.submit(utils.fetch_path, nix_bin_download_url)
108+
if bin_path:
109+
logger.info(f"Successfully built binary for {purl} to {bin_path}")
110+
warning_message = (
111+
f"Binary not found in cache for {purl}. Built locally using "
112+
f"commit {concluded_commit_hash} with a Linux-based Nix "
113+
f"Docker container."
114+
)
115+
logger.warning(warning_message)
116+
else:
117+
error_message = f"Failed to fetch or build the binary for {purl}"
118+
logger.error(error_message)
119+
120+
src_path = ""
121+
if concluded_commit_hash:
122+
src_path = get_patched_source_with_docker(
123+
name, output_dir, system, concluded_commit_hash
124+
)
107125

108-
for key, future in futures.items():
109-
try:
110-
result = future.result(timeout=600)
111-
if key == "source":
112-
src_path = result
113-
else:
114-
bin_path = result
115-
if bin_path:
116-
logger.info(f"Downloaded binary for {purl} to {bin_path}")
117-
else:
118-
logger.info(f"Unable to download the binary for {purl}")
126+
return src_path, bin_path, output_format, error_message, warning_message
127+
128+
129+
def build_binary_with_docker(name, output_dir, system, commit_hash, output_format):
130+
"""
131+
Fetch a Nix package and build its binary from source using Docker.
132+
Exports the resulting store path as a .nar file for standard extraction.
133+
"""
134+
nar_filename = f"{name}-bin.nar"
135+
extracted_path = Path(output_dir) / nar_filename
136+
absolute_out_dir = str(Path(output_dir).resolve())
137+
138+
# Handle architecture and system incompatibilities
139+
target_os = system.split("-")[-1] if "-" in system else system
140+
if target_os and target_os != "linux":
141+
logger.warning(
142+
f"SYSTEM BARRIER DETECTED: Target system '{system}' requires "
143+
f"OS-specific SDKs that cannot be evaluated inside the "
144+
f"Linux-based Nix Docker container. Defaulting the build to "
145+
f"the container's native Linux architecture."
146+
)
147+
system_config = ""
148+
else:
149+
system_config = (
150+
f'localSystem = builtins.currentSystem; crossSystem = "{system}";'
151+
)
119152

120-
except concurrent.futures.TimeoutError:
121-
logger.error(f"Timeout waiting for {key} to fetch (exceeded 600s).")
122-
except Exception as e:
123-
logger.error(f"Failed to fetch {key}: {e}")
153+
config_str = (
154+
"config = { "
155+
"allowBroken = true; "
156+
"allowUnfree = true; "
157+
"allowUnsupportedSystem = true; "
158+
"};"
159+
)
124160

125-
return src_path, bin_path, output_format
161+
nixpkgs_import = (
162+
f'import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/'
163+
f'{commit_hash}.tar.gz") {{ {system_config} {config_str} }}'
164+
)
165+
166+
# Defaulting to 'debug' if none is specified.
167+
effective_output = output_format or "debug"
168+
169+
# Fall back to the default target if the effective_output is not
170+
# defined in the recipe for this package.
171+
nix_expression = (
172+
f"let "
173+
f" pkgs = {nixpkgs_import}; "
174+
f" target = pkgs.{name}; "
175+
f" hasIt = builtins.isAttrs target && "
176+
f'builtins.hasAttr "{effective_output}" target; '
177+
f"in if hasIt then target.{effective_output} else target"
178+
)
179+
180+
# Build the Nix package, verify it succeeded, and export the output as
181+
# a .nar file.
182+
container_script = f"""
183+
OUT_PATH=$(nix-build --no-out-link -E '{nix_expression}')
184+
if [ -z "$OUT_PATH" ] || [ ! -e "$OUT_PATH" ]; then
185+
echo "Error: nix-build failed to return a valid store path." >&2
186+
exit 1
187+
fi
188+
nix-store --dump "$OUT_PATH" > /build_output/{nar_filename}
189+
"""
190+
191+
cmd = [
192+
"docker",
193+
"run",
194+
"--rm",
195+
"-v",
196+
"nix-eval-cache:/nix",
197+
"-v",
198+
f"{absolute_out_dir}:/build_output",
199+
"nixos/nix",
200+
"/bin/sh",
201+
"-c",
202+
container_script,
203+
]
204+
205+
task_description = f"Building ({name} for {system})"
206+
207+
try:
208+
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=1800) # noqa: S603
209+
if extracted_path.exists():
210+
return str(extracted_path)
211+
return ""
212+
except subprocess.CalledProcessError as e:
213+
logger.error(f"Failed: {task_description} with error: {e.stderr.strip()}")
214+
except subprocess.TimeoutExpired:
215+
logger.error(f"Failed: {task_description} with error: Process timed out")
216+
return ""
126217

127218

128219
def get_nix_store_path(data, name, version, system, commit_hash, user_output):
@@ -150,9 +241,9 @@ def get_nix_store_path(data, name, version, system, commit_hash, user_output):
150241
if not commit_hash:
151242
raise Exception(
152243
"Please provide a 'commit' qualifier in the PURL "
153-
"for Nix to determine the download URL."
244+
"for Nix to determine the download URL or build it locally."
154245
)
155-
raise Exception(f"Unable to determine the download URL for {name}")
246+
output_format = user_output or "debug"
156247

157248
return output_format, path, release_commit_hash
158249

@@ -328,7 +419,7 @@ def extract_nar_archive(archive_path, output_dir, output):
328419
"-v",
329420
f"{output_dir}:/output",
330421
"nixos/nix",
331-
"sh",
422+
"/bin/sh",
332423
"-c",
333424
container_script,
334425
]
@@ -425,7 +516,7 @@ def get_patched_source_with_docker(name, output_dir, system, commit_hash):
425516
"-v",
426517
f"{absolute_out_dir}:/build_output",
427518
"nixos/nix",
428-
"sh",
519+
"/bin/sh",
429520
"-c",
430521
container_script,
431522
]

scanpipe/tests/pipes/test_nix.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def test_scanpipe_nix_get_narinfo_url(self, mock_requests_get):
216216
self.assertEqual(url_path, "nar/123.nar.xz")
217217

218218
@mock.patch("scanpipe.pipes.nix.get_package_data")
219-
@mock.patch("scanpipe.pipes.nix.get_commit_hash_nix_store_path")
219+
@mock.patch("scanpipe.pipes.nix.get_nix_store_path_with_nix")
220220
@mock.patch("scanpipe.pipes.nix.get_nix_download_url")
221221
@mock.patch("scanpipe.pipes.nix.get_patched_source_with_docker")
222222
@mock.patch("scanpipe.pipes.utils.fetch_path")
@@ -225,11 +225,12 @@ def test_scanpipe_nix_fetch_inputs(
225225
mock_fetch_path,
226226
mock_get_patched_source,
227227
mock_get_download_url,
228-
mock_get_store_path,
228+
mock_get_store_path_with_nix,
229229
mock_get_package_data,
230230
):
231-
mock_get_package_data.return_value = {"releases": []}
232-
mock_get_store_path.return_value = ("1234abcd", "/nix/store/aaaaaaaaaa")
231+
mock_get_package_data.return_value = None
232+
mock_get_store_path_with_nix.return_value = "/nix/store/aaaaaaaaaa"
233+
233234
mock_get_download_url.return_value = "https://cache.nixos.org/nar/hello.nar.xz"
234235
mock_get_patched_source.return_value = "/path/extracted/from"
235236
mock_fetch_path.return_value = "/path/debug/to"
@@ -239,15 +240,71 @@ def test_scanpipe_nix_fetch_inputs(
239240
)
240241

241242
with tempfile.TemporaryDirectory() as temp_dir:
242-
src_path, bin_path, output_fmt = nix.fetch_inputs(purl, temp_dir)
243+
src_path, bin_path, output_fmt, error_msg, warning_msg = nix.fetch_inputs(
244+
purl, temp_dir
245+
)
243246

244247
self.assertEqual(src_path, "/path/extracted/from")
245248
self.assertEqual(bin_path, "/path/debug/to")
246249
self.assertEqual(output_fmt, "debug")
250+
self.assertEqual(error_msg, "")
251+
self.assertEqual(warning_msg, "")
252+
253+
mock_get_store_path_with_nix.assert_called_once()
254+
255+
@mock.patch("scanpipe.pipes.nix.get_package_data")
256+
@mock.patch("scanpipe.pipes.nix.get_nix_store_path_with_nix")
257+
@mock.patch("scanpipe.pipes.nix.get_nix_download_url")
258+
@mock.patch("scanpipe.pipes.nix.get_patched_source_with_docker")
259+
@mock.patch("scanpipe.pipes.nix.build_binary_with_docker")
260+
@mock.patch("scanpipe.pipes.utils.fetch_path")
261+
def test_scanpipe_nix_fetch_inputs_fallback_build(
262+
self,
263+
mock_fetch_path,
264+
mock_build_binary,
265+
mock_get_patched_source,
266+
mock_get_download_url,
267+
mock_get_store_path_with_nix,
268+
mock_get_package_data,
269+
):
270+
"""Test that fetch_inputs falls back to local build if download fails."""
271+
mock_get_package_data.return_value = None
272+
mock_get_store_path_with_nix.return_value = "/nix/store/aaaaaaaaaa"
273+
274+
# Simulate a missing/failed cache download
275+
mock_get_download_url.return_value = ""
276+
mock_fetch_path.return_value = ""
277+
278+
# Simulate a successful local build and source extraction
279+
mock_build_binary.return_value = "/path/built/locally/to"
280+
mock_get_patched_source.return_value = "/path/extracted/from"
281+
282+
purl = PackageURL.from_string(
283+
"pkg:nix/nixpkgs/hello@2.12.1?system=x86_64-linux&commit=1234abcd"
284+
)
285+
286+
with tempfile.TemporaryDirectory() as temp_dir:
287+
src_path, bin_path, output_fmt, error_msg, warning_msg = nix.fetch_inputs(
288+
purl, temp_dir
289+
)
290+
291+
self.assertEqual(src_path, "/path/extracted/from")
292+
self.assertEqual(bin_path, "/path/built/locally/to")
293+
self.assertEqual(output_fmt, "debug")
294+
self.assertEqual(error_msg, "")
295+
self.assertTrue("Built locally using commit" in warning_msg)
296+
297+
mock_build_binary.assert_called_once()
298+
mock_get_store_path_with_nix.assert_called_once()
247299

248300
@mock.patch("scanpipe.pipes.nix.get_commit_hash_nix_store_path")
249-
def test_scanpipe_nix_get_nix_store_path_success(self, mock_get_store_path):
250-
mock_get_store_path.return_value = ("1234abcd", "/nix/store/hello-path")
301+
def test_scanpipe_nix_get_nix_store_path_success(
302+
self, mock_get_commit_hash_nix_store_path
303+
):
304+
mock_get_commit_hash_nix_store_path.return_value = (
305+
"1234abcd",
306+
"/nix/store/hello-path",
307+
)
251308

252309
output_fmt, path, commit = nix.get_nix_store_path(
253310
data={"releases": []},
@@ -261,3 +318,38 @@ def test_scanpipe_nix_get_nix_store_path_success(self, mock_get_store_path):
261318
self.assertEqual(output_fmt, "debug")
262319
self.assertEqual(path, "/nix/store/hello-path")
263320
self.assertEqual(commit, "1234abcd")
321+
322+
@mock.patch("scanpipe.pipes.nix.subprocess.run")
323+
def test_scanpipe_nix_get_patched_source_with_docker_success(
324+
self, mock_subprocess_run
325+
):
326+
"""Test successful fetching and patching of source using Docker."""
327+
mock_subprocess_run.return_value = mock.Mock(returncode=0)
328+
329+
with tempfile.TemporaryDirectory() as temp_dir:
330+
result = nix.get_patched_source_with_docker(
331+
name="hello",
332+
output_dir=temp_dir,
333+
system="x86_64-linux",
334+
commit_hash="1234abcd",
335+
)
336+
337+
expected_path = str(Path(temp_dir) / "from")
338+
self.assertEqual(result, expected_path)
339+
mock_subprocess_run.assert_called_once()
340+
341+
@mock.patch("scanpipe.pipes.nix.subprocess.run")
342+
def test_scanpipe_nix_extract_nar_archive_success(self, mock_subprocess_run):
343+
"""Test extracting a .nar archive via Docker."""
344+
mock_subprocess_run.return_value = mock.Mock(returncode=0)
345+
346+
with tempfile.TemporaryDirectory() as temp_dir:
347+
# We don't actually need the file to exist for the mocked test
348+
archive_path = Path(temp_dir) / "hello-bin.nar.xz"
349+
350+
result = nix.extract_nar_archive(
351+
archive_path=str(archive_path), output_dir=temp_dir, output="debug"
352+
)
353+
354+
expected_extracted_path = str(Path(temp_dir) / "to" / "debug")
355+
self.assertEqual(result, expected_extracted_path)

0 commit comments

Comments
 (0)