Skip to main content

max / alloy

build: an installer ISO that boots into alloy install bootc-image-builder cannot produce this. Every ISO type it offers ends in Anaconda, and the installer binary lives in the bootc image rather than in the live root bib composes from the anaconda-iso package set. So the ISO is assembled here instead, and build/build-image.sh keeps only its disk image types. The builder derives FROM the Alloy image rather than stock Fedora, so the live initramfs is built against exactly the kernel that ships; a builder on its own base produces an initramfs for a different kernel and fails at boot with nothing pointing at why. dracut-live and xorriso live in that throwaway stage because neither belongs on an installed machine. The ISO carries Alloy twice, for two different jobs: a squashfs the installer runs in, and an oci-archive that gets deployed, because bootc installs a container image and cannot install a running squashfs. Same content, paid for twice, and that is what buys an install with no network. alloy-installer.service is gated on a kernel command line flag only the GRUB entries here set, rather than shipped disabled and enabled by the ISO build. The same image is both the live installer and the system it installs, and an installer that could start on an installed machine is a way to lose a disk.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 18:52 UTC
Signed with PGP, not checked
Commit: 04abcd1fc2fff5ae7be71d8ede1f09266a05b57d
Parent: 3eb749c
6 files changed, +377 insertions, -2 deletions
@@ -5,8 +5,9 @@
5 5 # alone is a multi-gigabyte ISO, which the build was shipping to itself on
6 6 # every run.
7 7 #
8 - # Keep this in step with the COPY lines in the Containerfile: the build
9 - # reads Cargo.toml, Cargo.lock, crates/, etc/, and usr/, and nothing else.
8 + # Keep this in step with the COPY lines in the Containerfiles: the image
9 + # build reads Cargo.toml, Cargo.lock, crates/, etc/ and usr/, and
10 + # build/Containerfile.iso additionally reads build/make-iso.sh.
10 11
11 12 # Cargo artifacts. The rust-build stage compiles from scratch inside the
12 13 # image on purpose — host artifacts are built against a different libc and
@@ -19,7 +20,10 @@
19 20
20 21 # Not read by any COPY.
21 22 /.git
23 + # Excluded except the one script build/Containerfile.iso copies in. The
24 + # negation has to follow the exclusion; reversed, it does nothing.
22 25 /build
26 + !/build/make-iso.sh
23 27 /builds.disabled
24 28 /docs
25 29 /schemas
@@ -16,5 +16,9 @@
16 16 # a match fall through to /usr/lib/systemd/system-preset/ defaults.
17 17
18 18 enable greetd.service
19 + # Inert unless the kernel command line carries `alloy.installer`, which only
20 + # the installer ISO sets, so enabling it on every install is safe and keeps
21 + # the live medium from needing a modified copy of the image.
22 + enable alloy-installer.service
19 23
20 24 disable tailscaled.service
@@ -1,0 +1,33 @@
1 + # Builder image for the Alloy installer ISO.
2 + #
3 + # Derived FROM the Alloy image rather than from stock Fedora, so the live
4 + # initramfs is built against exactly the kernel and modules that ship. A
5 + # builder on its own base would produce an initramfs for a different kernel
6 + # and fail at boot with no obvious cause.
7 + #
8 + # The extra packages live here instead of in the image itself because none
9 + # of them belong on an installed machine: dracut-live drives a squashfs
10 + # root, and xorriso only ever assembles ISOs.
11 + ARG BASE=localhost/alloy:local
12 + FROM ${BASE}
13 +
14 + # dnf is only usable here because this stage is thrown away; the shipped
15 + # image disables the third-party repos it layered from at build time, so
16 + # nothing below reaches for them.
17 + RUN dnf install -y \
18 + dracut-live \
19 + xorriso \
20 + squashfs-tools \
21 + grub2-efi-x64 \
22 + grub2-efi-x64-modules \
23 + grub2-tools \
24 + grub2-tools-extra \
25 + shim-x64 \
26 + dosfstools \
27 + rsync \
28 + && dnf clean all
29 +
30 + COPY build/make-iso.sh /usr/local/bin/make-iso
31 + RUN chmod +x /usr/local/bin/make-iso
32 +
33 + ENTRYPOINT ["/usr/local/bin/make-iso"]
@@ -1,0 +1,119 @@
1 + #!/usr/bin/env bash
2 + #
3 + # build-iso.sh — build an Alloy installer ISO that boots into `alloy install`.
4 + #
5 + # This is the replacement for the bootc-image-builder path in
6 + # build/build-image.sh, which can only produce Anaconda ISOs: every ISO type
7 + # bib offers ends in Anaconda, and the installer binary lives in the bootc
8 + # image rather than in the live root bib composes from RPMs. See GO task
9 + # a1d037f8. build/build-image.sh keeps its disk-image types (raw, qcow2);
10 + # this script owns the ISO.
11 + #
12 + # Shape of the result:
13 + # LiveOS/squashfs.img the Alloy image, as the live system
14 + # source/alloy.oci the same image again, as what gets installed
15 + # EFI/BOOT + boot/ shim, grub, kernel, live initramfs
16 + #
17 + # The live system runs alloy-installer.service, which is inert unless the
18 + # kernel command line carries `alloy.installer`. Only the GRUB entries here
19 + # set it, so the unit cannot fire on an installed machine.
20 + #
21 + # Usage:
22 + # build/build-iso.sh # build image, then ISO
23 + # build/build-iso.sh --skip-build # reuse the current image
24 + # build/build-iso.sh --skip-source # omit the oci-archive (faster; the
25 + # # ISO boots but cannot install)
26 +
27 + set -euo pipefail
28 +
29 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
30 + IMAGE="localhost/alloy:local"
31 + BUILDER="localhost/alloy-iso-builder:local"
32 + OUTPUT="$REPO_ROOT/output"
33 + WORKDIR="$REPO_ROOT/output/.iso-work"
34 +
35 + SKIP_BUILD=0
36 + SKIP_SOURCE=0
37 +
38 + die() { printf 'error: %s\n' "$*" >&2; exit 1; }
39 + say() { printf '==> %s\n' "$*"; }
40 +
41 + while [ $# -gt 0 ]; do
42 + case "$1" in
43 + --skip-build) SKIP_BUILD=1; shift ;;
44 + --skip-source) SKIP_SOURCE=1; shift ;;
45 + -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
46 + *) die "unknown argument: $1 (see --help)" ;;
47 + esac
48 + done
49 +
50 + command -v podman >/dev/null || die "podman not found"
51 +
52 + # 1. The Alloy image.
53 + if [ "$SKIP_BUILD" -eq 0 ]; then
54 + say "building $IMAGE"
55 + sudo podman build -t "$IMAGE" "$REPO_ROOT"
56 + else
57 + sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
58 + say "reusing $IMAGE"
59 + fi
60 +
61 + # 2. The builder, derived from it so the initramfs matches the shipped kernel.
62 + say "building $BUILDER"
63 + sudo podman build -t "$BUILDER" \
64 + --build-arg "BASE=$IMAGE" \
65 + -f "$REPO_ROOT/build/Containerfile.iso" \
66 + "$REPO_ROOT"
67 +
68 + # 3. Somewhere to work. Rotated rather than deleted, same as build-image.sh:
69 + # a failed run should not take the last good ISO with it.
70 + if [ -d "$OUTPUT" ] && [ -n "$(sudo ls -A "$OUTPUT" 2>/dev/null)" ]; then
71 + say "rotating previous output to ${OUTPUT}.prev"
72 + sudo rm -rf "${OUTPUT:?}.prev"
73 + sudo mv "$OUTPUT" "${OUTPUT}.prev"
74 + fi
75 + sudo mkdir -p "$OUTPUT" "$WORKDIR/source"
76 +
77 + # 4. The image to install, as an oci-archive. skopeo rather than `podman
78 + # save` because bootc reads skopeo transports, and this is the exact
79 + # format --source-imgref will be handed.
80 + if [ "$SKIP_SOURCE" -eq 0 ]; then
81 + say "exporting the install source (several GB, and slow)"
82 + sudo skopeo copy \
83 + "containers-storage:$IMAGE" \
84 + "oci-archive:$WORKDIR/source/alloy.oci:alloy:local" \
85 + 2>&1 | tail -3 \
86 + || sudo podman run --rm --privileged \
87 + -v /var/lib/containers/storage:/var/lib/containers/storage \
88 + -v "$WORKDIR/source":/source \
89 + "$BUILDER" \
90 + --export-only
91 + else
92 + say "skipping the install source"
93 + fi
94 +
95 + # 5. Mount the image's root filesystem and assemble.
96 + #
97 + # `podman image mount` gives a path to the composed rootfs without
98 + # extracting it, which saves writing several GB to disk only to read it
99 + # straight back. Rootful, so it lands in the same store the image is in.
100 + say "mounting $IMAGE"
101 + ROOTFS="$(sudo podman image mount "$IMAGE")"
102 + [ -n "$ROOTFS" ] || die "could not mount $IMAGE"
103 + trap 'sudo podman image umount "$IMAGE" >/dev/null 2>&1 || true' EXIT
104 + say "rootfs at $ROOTFS"
105 +
106 + say "assembling the ISO"
107 + sudo podman run --rm --privileged \
108 + --security-opt label=type:unconfined_t \
109 + -v "$ROOTFS":/rootfs:ro \
110 + -v "$OUTPUT":/output \
111 + -v "$WORKDIR/source":/source:ro \
112 + "$BUILDER"
113 +
114 + ARTIFACT="$OUTPUT/install.iso"
115 + sudo test -f "$ARTIFACT" || die "no ISO produced"
116 + say "built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))"
117 + echo
118 + echo " Boot it, or write it with:"
119 + echo " build/build-image.sh --skip-bib --write /dev/sdX"
@@ -1,0 +1,175 @@
1 + #!/usr/bin/env bash
2 + #
3 + # make-iso.sh — assemble the Alloy installer ISO. Runs inside the builder
4 + # image from build/Containerfile.iso; build/build-iso.sh is what invokes it.
5 + #
6 + # Expects:
7 + # /rootfs the Alloy image's root filesystem, read-only
8 + # /output where install.iso is written
9 + # /source an oci-archive of the Alloy image, copied onto the ISO
10 + #
11 + # The ISO carries two copies of Alloy for two different jobs. The squashfs
12 + # is the live system the installer runs in. The oci-archive is what gets
13 + # deployed, because `bootc install` installs a container image and cannot
14 + # install a running squashfs. They are the same content and the ISO pays
15 + # for it twice; that is the cost of not needing a network to install.
16 +
17 + set -euo pipefail
18 +
19 + ROOTFS=/rootfs
20 + OUTPUT=/output
21 + SOURCE=/source
22 + WORK=/work
23 + VOLID="ALLOY"
24 +
25 + say() { printf '==> %s\n' "$*"; }
26 +
27 + [ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; }
28 + [ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; }
29 +
30 + KVER="$(ls "$ROOTFS/usr/lib/modules" | head -1)"
31 + [ -n "$KVER" ] || { echo "no kernel in $ROOTFS/usr/lib/modules" >&2; exit 1; }
32 + say "kernel $KVER"
33 +
34 + rm -rf "$WORK"
35 + mkdir -p "$WORK/iso/LiveOS" "$WORK/iso/EFI/BOOT" "$WORK/iso/boot/grub"
36 +
37 + # ---------------------------------------------------------------------
38 + # 1. The live root, as a squashfs holding an ext4 image.
39 + #
40 + # dmsquash-live expects LiveOS/squashfs.img to contain LiveOS/rootfs.img,
41 + # not the root tree directly. Handing it a squashfs of the tree boots to a
42 + # dracut shell with "failed to mount live root", which says nothing about
43 + # the layout being the problem.
44 + # ---------------------------------------------------------------------
45 + say "building rootfs.img"
46 + SIZE_KB="$(du -sk "$ROOTFS" | cut -f1)"
47 + # Slack for the ext4 metadata plus room for the overlay's early writes.
48 + IMG_MB=$(( SIZE_KB / 1024 + 1536 ))
49 + mkdir -p "$WORK/LiveOS"
50 + truncate -s "${IMG_MB}M" "$WORK/LiveOS/rootfs.img"
51 + mkfs.ext4 -q -L Alloy -d "$ROOTFS" "$WORK/LiveOS/rootfs.img"
52 +
53 + say "compressing squashfs.img (this is the long part)"
54 + mksquashfs "$WORK/LiveOS" "$WORK/iso/LiveOS/squashfs.img" \
55 + -noappend -no-progress -comp zstd -Xcompression-level 19 -b 1M \
56 + -e rootfs.img.tmp
57 + rm -rf "$WORK/LiveOS"
58 +
59 + # ---------------------------------------------------------------------
60 + # 2. Kernel and a live initramfs.
61 + #
62 + # --no-hostonly matters: a host-only initramfs is built for the hardware
63 + # doing the building, and this one has to boot anything.
64 + # ---------------------------------------------------------------------
65 + say "kernel and initramfs"
66 + cp "$ROOTFS/usr/lib/modules/$KVER/vmlinuz" "$WORK/iso/boot/vmlinuz"
67 +
68 + dracut --force --no-hostonly --nomdadmconf --nolvmconf \
69 + --kver "$KVER" \
70 + --kmoddir "$ROOTFS/usr/lib/modules/$KVER" \
71 + --add "dmsquash-live" \
72 + --add-drivers "squashfs loop overlay iso9660 sr_mod sd_mod usb_storage virtio_blk virtio_scsi virtio_pci" \
73 + "$WORK/iso/boot/initramfs.img"
74 +
75 + # ---------------------------------------------------------------------
76 + # 3. The image the installer deploys.
77 + # ---------------------------------------------------------------------
78 + if [ -d "$SOURCE" ] && [ -n "$(ls -A "$SOURCE" 2>/dev/null)" ]; then
79 + say "copying the install source onto the ISO"
80 + mkdir -p "$WORK/iso/source"
81 + cp -a "$SOURCE/." "$WORK/iso/source/"
82 + else
83 + echo "warning: no oci-archive at $SOURCE; the ISO will have nothing to install" >&2
84 + fi
85 +
86 + # ---------------------------------------------------------------------
87 + # 4. GRUB.
88 + #
89 + # `alloy.installer` is what arms alloy-installer.service; without it the
90 + # live system boots to a greeter with no account, which looks like a
91 + # broken ISO. rd.live.image and root=live:CDLABEL are what dmsquash-live
92 + # reads to find the squashfs.
93 + # ---------------------------------------------------------------------
94 + say "grub"
95 + CMDLINE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer quiet loglevel=3"
96 +
97 + cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF
98 + set default=0
99 + set timeout=5
100 +
101 + menuentry "Install Alloy" {
102 + linux /boot/vmlinuz $CMDLINE
103 + initrd /boot/initramfs.img
104 + }
105 +
106 + menuentry "Install Alloy (verbose, for diagnosing a failed boot)" {
107 + linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer rd.debug
108 + initrd /boot/initramfs.img
109 + }
110 + EOF
111 +
112 + # grub.cfg next to the EFI binary is the one the firmware reads first; it
113 + # hands off to the copy above so there is a single place to edit entries.
114 + mkdir -p "$WORK/iso/EFI/BOOT"
115 + cat > "$WORK/iso/EFI/BOOT/grub.cfg" <<EOF
116 + search --no-floppy --set=root --label $VOLID
117 + set prefix=(\$root)/boot/grub
118 + configfile /boot/grub/grub.cfg
119 + EOF
120 +
121 + # Signed shim first, so the ISO boots with Secure Boot on. grubx64.efi is
122 + # what shim chains to and the name is not arbitrary.
123 + cp "$ROOTFS/boot/efi/EFI/BOOT/BOOTX64.EFI" "$WORK/iso/EFI/BOOT/BOOTX64.EFI" 2>/dev/null \
124 + || cp /boot/efi/EFI/BOOT/BOOTX64.EFI "$WORK/iso/EFI/BOOT/BOOTX64.EFI" 2>/dev/null \
125 + || cp /usr/share/shim/x64/shimx64.efi "$WORK/iso/EFI/BOOT/BOOTX64.EFI"
126 + cp /usr/share/shim/x64/mmx64.efi "$WORK/iso/EFI/BOOT/mmx64.efi" 2>/dev/null || true
127 +
128 + grub2-mkimage \
129 + --format=x86_64-efi \
130 + --prefix="/EFI/BOOT" \
131 + --output="$WORK/iso/EFI/BOOT/grubx64.efi" \
132 + part_gpt part_msdos fat iso9660 udf normal linux echo all_video test \
133 + search search_label search_fs_uuid search_fs_file gfxterm gfxterm_background \
134 + configfile loadenv chain efi_gop efi_uga ls cat halt reboot minicmd \
135 + font terminal squash4 loopback probe regexp
136 +
137 + # The EFI system partition the firmware actually mounts. Sized to contents
138 + # rather than a round number, because a fixed size is a future failure the
139 + # day the EFI binaries grow.
140 + say "efiboot.img"
141 + EFI_KB=$(( $(du -sk "$WORK/iso/EFI" | cut -f1) + 2048 ))
142 + truncate -s "${EFI_KB}K" "$WORK/efiboot.img"
143 + mkfs.fat -n ALLOYEFI "$WORK/efiboot.img" >/dev/null
144 + mmd -i "$WORK/efiboot.img" ::/EFI ::/EFI/BOOT 2>/dev/null || {
145 + # mtools absent; fall back to a loop mount, which needs privileges the
146 + # build already has.
147 + mkdir -p "$WORK/efimnt"
148 + mount -o loop "$WORK/efiboot.img" "$WORK/efimnt"
149 + mkdir -p "$WORK/efimnt/EFI/BOOT"
150 + cp -a "$WORK/iso/EFI/BOOT/." "$WORK/efimnt/EFI/BOOT/"
151 + umount "$WORK/efimnt"
152 + }
153 + if command -v mcopy >/dev/null; then
154 + mcopy -i "$WORK/efiboot.img" -s "$WORK/iso/EFI/BOOT/"* ::/EFI/BOOT/ 2>/dev/null || true
155 + fi
156 + cp "$WORK/efiboot.img" "$WORK/iso/efiboot.img"
157 +
158 + # ---------------------------------------------------------------------
159 + # 5. Assemble. UEFI only, matching the images bootc produces: they have no
160 + # legacy BIOS path, so a hybrid MBR would advertise a boot that fails.
161 + # ---------------------------------------------------------------------
162 + say "xorriso"
163 + xorriso -as mkisofs \
164 + -iso-level 3 \
165 + -volid "$VOLID" \
166 + -appid "Alloy Installer" \
167 + -eltorito-alt-boot \
168 + -e efiboot.img \
169 + -no-emul-boot \
170 + -isohybrid-gpt-basdat \
171 + -output "$OUTPUT/install.iso" \
172 + "$WORK/iso"
173 +
174 + chmod 0644 "$OUTPUT/install.iso"
175 + say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso"
@@ -1,0 +1,40 @@
1 + # Runs the Alloy installer on VT1 when booted from the installer ISO.
2 + #
3 + # Gated on a kernel command line flag rather than shipped disabled and
4 + # enabled by the ISO build: the same image is both the live installer and
5 + # the system it installs, and an installer that could start on an installed
6 + # machine is a way to lose a disk. `alloy.installer` is set only by the
7 + # GRUB entries in build/build-iso.sh, so this unit is inert everywhere else.
8 + #
9 + # Conflicts with greetd because both want VT1. On the live medium there is
10 + # no account to log into yet, so the greeter has nothing to offer and would
11 + # only race the installer for the terminal.
12 + [Unit]
13 + Description=Alloy installer
14 + Documentation=https://git.sr.ht/~maxmj/alloy
15 + ConditionKernelCommandLine=alloy.installer
16 + After=systemd-user-sessions.service systemd-logind.service
17 + Before=getty.target
18 + Conflicts=getty@tty1.service greetd.service
19 +
20 + [Service]
21 + # Type=idle so the boot's own console output has finished before a
22 + # full-screen TUI takes the terminal; without it the first frame draws
23 + # over service status lines and the user sees a torn screen.
24 + Type=idle
25 + ExecStart=/usr/bin/alloy install
26 + TTYPath=/dev/tty1
27 + StandardInput=tty
28 + StandardOutput=tty
29 + StandardError=journal
30 + TTYReset=yes
31 + TTYVHangup=yes
32 + TTYVTDisallocate=yes
33 +
34 + # A crashed installer must not leave a blank VT with no way forward. It is
35 + # also not something to retry forever: the disk may be half written, so
36 + # coming back to a fresh wizard would invite a second pass over it.
37 + Restart=no
38 +
39 + [Install]
40 + WantedBy=multi-user.target