diff --git a/clan_lib/machines/build.py b/clan_lib/machines/build.py index 00e2b16..d9ed5a1 100644 --- a/clan_lib/machines/build.py +++ b/clan_lib/machines/build.py @@ -98,32 +98,42 @@ def _hostplatform_unset_hint(machine: Machine, build_format: str) -> str: ) -def _is_image_format(build_format: str) -> bool: - """Check if the build format is an image format that supports secret injection.""" - return build_format not in ("toplevel") +def _image_add_files_script(machine: Machine, build_format: str) -> str | None: + """Return the format's addFilesScript store path. + + None when clan has no file-injection support for this format + (i.e. no `clan.core.image..addFilesScript` option), in which + case secret injection must be skipped rather than fail. + """ + result = machine.flake.select_machine( + machine.name, + f"config.clan.core.image.?{build_format}.addFilesScript", + ) + return result.get(build_format) -def _find_image_file(build_dir: Path, build_format: str) -> Path: - """Find the actual image file within a build output directory. +def _find_image_file(machine: Machine, build_dir: Path, build_format: str) -> Path: + """Resolve the image file within an image build output directory. - Image builds output a directory (e.g., /nix/store/...-x86_64-linux.iso/) - containing the image file in a subdirectory named after the format - (e.g., iso/nixos-26.05-x86_64-linux.iso). + Image builds output a directory whose layout is format-specific + (iso/nixos-….iso, sd-image/nixos-….img.zst, …). nixpkgs exposes the + relative location as `config.image.filePath`, attached to the image + derivation as `passthru.filePath`. """ - format_dir = build_dir / build_format - if not format_dir.is_dir(): - msg = f"Expected directory {format_dir} not found in build output" - raise ClanError(msg) - files = [f for f in format_dir.iterdir() if f.is_file()] - if len(files) != 1: - msg = f"Expected exactly one file in {format_dir}, found {len(files)}" + file_path: str = machine.flake.select_machine( + machine.name, + f"config.system.build.images.{build_format}.passthru.filePath", + ) + image_file = build_dir / file_path + if not image_file.is_file(): + msg = f"Expected image file {image_file} not found in build output" raise ClanError(msg) - return files[0] + return image_file def _inject_secrets_into_image( machine: Machine, - build_format: str, + add_files_script: str, source_image_path: Path, output_path: Path, *, @@ -137,7 +147,9 @@ def _inject_secrets_into_image( secrets_target_dir = machine.secret_vars_store.get_upload_directory(machine.name) with TemporaryDirectory(prefix="clan-build-secrets-") as tmpdir: - base_dir = Path(tmpdir).resolve() + # The tree mapped to "/" inside the image lives in its own subdir so + # the format script never sees its own output as input. + base_dir = Path(tmpdir).resolve() / "root" upload_dir = base_dir / secrets_target_dir.lstrip("/") upload_dir.mkdir(parents=True) @@ -153,12 +165,6 @@ def _inject_secrets_into_image( if not any(upload_dir.iterdir()): return False - # Get the addFilesScript path from the NixOS config - script_path = machine.flake.select_machine( - machine.name, - f"config.clan.core.image.{build_format}.addFilesScript", - ) - # Remove stale output from previous runs (xorriso refuses to # write to an existing non-empty file when indev != outdev) output = Path(output_path) @@ -167,12 +173,12 @@ def _inject_secrets_into_image( # Write the image into the tmpdir so the sandbox only needs # write access to the temp directory, not the final destination. - sandbox_output = base_dir / "output" / output.name + sandbox_output = base_dir.parent / "output" / output.name sandbox_output.parent.mkdir(parents=True) # Execute the addFilesScript; clean up partial output on failure inject_cmd: list[str] = [ - f"{script_path}/bin/add-files", + f"{add_files_script}/bin/add-files", "--source", str(source_image_path), "--output", @@ -265,33 +271,40 @@ def build_machine( if build_path and (store := nix_test_store()): build_path = store / str(build_path).lstrip("/") - # Post-build secret injection for image formats + # Post-build secret injection for image formats that support it if ( build_path - and _is_image_format(options.format) + and options.format != "toplevel" and not options.no_secrets and not options.dry_run ): - # build_path is a directory; resolve the actual image file inside - source_file = _find_image_file(build_path, options.format) - # Output to a file next to the symlink, named after the source file - output_file = Path.cwd() / source_file.name - - secrets_injected = _inject_secrets_into_image( - machine=machine, - build_format=options.format, - source_image_path=source_file, - output_path=output_file, - use_sandbox=options.use_sandbox, - ) - if secrets_injected: - log.warning( - "This image contains secret key material " - "(age private key). Treat this image as a sensitive " - "artifact. Do not store it on shared or public media " - "without encryption. Securely erase after use." + add_files_script = _image_add_files_script(machine, options.format) + if add_files_script is None: + log.info( + f"Image format '{options.format}' has no addFilesScript; " + "skipping secret injection" ) - build_path = output_file + else: + # build_path is a directory; resolve the actual image file inside + source_file = _find_image_file(machine, build_path, options.format) + # Output to a file next to the symlink, named after the source file + output_file = Path.cwd() / source_file.name + + secrets_injected = _inject_secrets_into_image( + machine=machine, + add_files_script=add_files_script, + source_image_path=source_file, + output_path=output_file, + use_sandbox=options.use_sandbox, + ) + if secrets_injected: + log.warning( + "This image contains secret key material " + "(age private key). Treat this image as a sensitive " + "artifact. Do not store it on shared or public media " + "without encryption. Securely erase after use." + ) + build_path = output_file return BuildResult( machine_name=machine.name,