`clan machines build cursa --format sd-card` failed in clan-cli's post-build secret injection: Expected directory /nix/store/...-nixos-image-sd-card-...img.zst/sd-card not found in build output clan-cli assumed $out/<format>/ (only true for iso; sd-card writes $out/sd-image/) and tried to inject into every format although only iso has an addFilesScript. Fixed upstream in patches/upstream-sd-card-secret-injection.patch (pending PR); until it lands: - shell.nix: apply patches/clan-cli-image-filepath.patch (the clan-cli part of the upstream change) to the devshell's clan-cli. - modules/nixos/sd-card-image.nix: local copy of the upstream clan.core.image.sd-card.addFilesScript module. Writes the vars tree directly into the ext4 root partition with debugfs (no boot-time hook), verifies with cmp + e2fsck. Delete once clan-core ships it. - cursa imports it and builds its sd-card image without disko.
166 lines
7.0 KiB
Diff
166 lines
7.0 KiB
Diff
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.<format>.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,
|