Skip to main content

max / alloy

install: name the image to install when booted from the medium bootc installs a container image and by default expects to be running inside the one it installs. On the ISO it is not: it runs in the live system, which is a squashfs, and a squashfs is not a container image. So the plan's first stage failed there with "this command must be invoked inside of the container", which is the rework the installer task has carried since the plan was written for the wrong context. The image travels on the medium as an oci-archive and is named with --source-imgref. Detected from the path rather than configured, because the same binary runs in both places: present means booted from the installer medium, absent means inside a container during development, where bootc's own default is already right. Also untracks output.prev and ignores it. The rotation added in 3eb749c to stop a failed build destroying the last good artifact created a directory .gitignore did not cover, so a 2.3G ISO was committed in da89a49.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 20:12 UTC
Commit: 30a32e81aee901d1dd3c53370c57ac3f1af6c845
Parent: 43a3ed4
2 files changed, +73 insertions, -5 deletions
M .gitignore +4
@@ -14,6 +14,10 @@
14 14
15 15 # bootc-image-builder output (ISO/raw/qcow2 + manifest)
16 16 /output/
17 + # ...and the rotated copy build-iso.sh/build-image.sh leave behind. Without
18 + # this the rotation meant to protect the last good artifact instead commits
19 + # a multi-gigabyte ISO, which is how a 2.3G blob reached this repo once.
20 + /output.prev/
17 21
18 22 # Environment
19 23 .env
@@ -596,6 +596,33 @@
596 596 ])
597 597 }
598 598
599 + /// Where the installer ISO carries the image it installs.
600 + ///
601 + /// dmsquash-live mounts the medium here, and build/make-iso.sh puts the
602 + /// oci-archive at `source/` on it. The two are a pair: renaming either
603 + /// leaves an installer that runs and cannot install.
604 + const LIVE_SOURCE: &str = "/run/initramfs/live/source/alloy.oci";
605 +
606 + /// The `--source-imgref` for this run, or `None` when bootc's own default
607 + /// applies.
608 + ///
609 + /// Returning an owned skopeo reference rather than a bare path because the
610 + /// transport is part of the answer: bootc reads skopeo formats, and an
611 + /// `oci-archive:` prefix is what makes a file on the medium a thing it can
612 + /// deploy from.
613 + fn live_source_image() -> Option<String> {
614 + source_image_at(std::path::Path::new(LIVE_SOURCE))
615 + }
616 +
617 + /// The reference for an archive at `path`, if it is there.
618 + ///
619 + /// Split from [`live_source_image`] so the transport prefix can be tested
620 + /// without an installer medium to hand.
621 + fn source_image_at(path: &std::path::Path) -> Option<String> {
622 + path.exists()
623 + .then(|| format!("oci-archive:{}", path.display()))
624 + }
625 +
599 626 /// The whole install, as stages.
600 627 ///
601 628 /// Two discoveries, nested. bootc decides which partition holds the new root
@@ -611,11 +638,23 @@
611 638 // --wipe is explicit rather than implied by the confirm the user just
612 639 // answered: the flag that destroys the disk should be visible on the
613 640 // line the summary displays, not hidden in a default.
614 - Stage::Run(
615 - Invocation::new("bootc")
616 - .args(["install", "to-disk", "--wipe"])
617 - .arg(disk),
618 - ),
641 + Stage::Run({
642 + let mut install = Invocation::new("bootc").args(["install", "to-disk", "--wipe"]);
643 + // bootc installs a container image, and by default it expects to
644 + // be running inside the one it is installing. On the ISO it is
645 + // not: it runs in the live system, which is a squashfs, and a
646 + // squashfs is not a container image. So the image travels
647 + // separately as an oci-archive and is named here.
648 + //
649 + // Detected rather than configured, because the same binary runs
650 + // in both places. Present means booted from the installer
651 + // medium; absent means running inside a container during
652 + // development, where bootc's own default is already right.
653 + if let Some(source) = live_source_image() {
654 + install = install.args(["--source-imgref", &source]);
655 + }
656 + install.arg(disk)
657 + }),
619 658 // bootc returns when the install is done, not when the kernel and
620 659 // udev have caught up with the partition table it wrote. In that
621 660 // window `lsblk` answers with a `parttype` of null for every
@@ -2434,6 +2473,31 @@
2434 2473 assert!(error.contains("no root partition"), "{error}");
2435 2474 }
2436 2475
2476 + // bootc reads skopeo transports, so the prefix is not decoration: handed
2477 + // a bare path it looks for a registry by that name and fails saying so.
2478 + #[test]
2479 + fn a_source_image_is_named_with_its_transport() {
2480 + let dir = std::env::temp_dir().join("alloy-source-test");
2481 + std::fs::create_dir_all(&dir).unwrap();
2482 + let archive = dir.join("alloy.oci");
2483 + std::fs::write(&archive, b"not really an archive").unwrap();
2484 +
2485 + let named = source_image_at(&archive).expect("an archive that is there is named");
2486 + assert!(named.starts_with("oci-archive:"), "{named}");
2487 + assert!(named.ends_with("alloy.oci"), "{named}");
2488 +
2489 + std::fs::remove_file(&archive).unwrap();
2490 + }
2491 +
2492 + // Absent means running inside a container during development, where
2493 + // bootc installs the image it is running and needs no help.
2494 + #[test]
2495 + fn no_source_image_means_bootc_uses_its_own_default() {
2496 + let missing = std::env::temp_dir().join("alloy-no-such-archive.oci");
2497 + let _ = std::fs::remove_file(&missing);
2498 + assert_eq!(source_image_at(&missing), None);
2499 + }
2500 +
2437 2501 #[test]
2438 2502 fn a_malformed_listing_is_a_named_failure() {
2439 2503 assert!(root_partition("not json").unwrap_err().contains("JSON"));