Files
infra/patches/clan-cli-image-filepath.patch
T
kurogeek b4f9ae3f94 cursa: build sd-card image with secret injection
`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.
2026-09-29 15:33:22 +07:00

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,