Skip to main content

max / alloy

build: make the installer ISO actually boot Five fixes, each found by booting the thing. The live root is a squashfs containing LiveOS/rootfs.img, and mksquashfs takes the *contents* of the directory it is given as the squashfs root, so pointing it at LiveOS/ put rootfs.img at the top level. dracut then said "Failed to find a root filesystem in .../squashfs.img" while looking for LiveOS/rootfs.img. Point it at the parent. skopeo is not on this host, so the oci-archive export runs in the builder with the container store bind mounted, rather than making the build depend on which distro is running it. Nothing restores the EFI binaries a bootc image strips out of /boot, and the packages still read as installed, so shim never appeared. Dropped shim entirely: a self-built GRUB is unsigned either way, so this ISO needs Secure Boot off regardless. Signing waits on distribution. selinux=0 on the live medium, because the live rootfs is built by mkfs.ext4 on a host with no SELinux and carries no labels at all, and systemd will not start without them: "Failed to allocate manager object: Permission denied", then it freezes, saying nothing about labelling. console= is listed twice so serial carries a full log for headless debugging while /dev/console stays on the screen the user is looking at. Without it a failed boot is a black rectangle. Also adds --fast for iteration, an initramfs shell entry for when the live root will not mount, and a check that dmsquash-live is really in the initramfs, since dracut reports failures and exits 0 regardless.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 19:53 UTC
Commit: 2e135a19fa176c9c9f9aba3ef8570eef459fcf4a
Parent: 04abcd1
3 files changed, +107 insertions, -28 deletions
@@ -22,7 +22,6 @@
22 22 grub2-efi-x64-modules \
23 23 grub2-tools \
24 24 grub2-tools-extra \
25 - shim-x64 \
26 25 dosfstools \
27 26 rsync \
28 27 && dnf clean all
@@ -23,6 +23,7 @@
23 23 # build/build-iso.sh --skip-build # reuse the current image
24 24 # build/build-iso.sh --skip-source # omit the oci-archive (faster; the
25 25 # # ISO boots but cannot install)
26 + # build/build-iso.sh --fast # iteration: skip export, cheap compression
26 27
27 28 set -euo pipefail
28 29
@@ -34,6 +35,7 @@
34 35
35 36 SKIP_BUILD=0
36 37 SKIP_SOURCE=0
38 + FAST=0
37 39
38 40 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
39 41 say() { printf '==> %s\n' "$*"; }
@@ -42,6 +44,10 @@
42 44 case "$1" in
43 45 --skip-build) SKIP_BUILD=1; shift ;;
44 46 --skip-source) SKIP_SOURCE=1; shift ;;
47 + # Iteration mode: skip the multi-GB export and compress cheaply. The
48 + # result boots and cannot install, which is the right trade while the
49 + # question is still whether the live chain works at all.
50 + --fast) SKIP_SOURCE=1; SKIP_BUILD=1; FAST=1; shift ;;
45 51 -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
46 52 *) die "unknown argument: $1 (see --help)" ;;
47 53 esac
@@ -79,15 +85,17 @@
79 85 # format --source-imgref will be handed.
80 86 if [ "$SKIP_SOURCE" -eq 0 ]; then
81 87 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
88 + # skopeo from the builder rather than the host: this box is Pop!_OS and
89 + # has no skopeo, and requiring one would make the build depend on which
90 + # distro happens to be running it. The host's container store is bind
91 + # mounted in so skopeo can read the image out of it.
92 + sudo podman run --rm --privileged \
93 + --security-opt label=type:unconfined_t \
94 + -v /var/lib/containers/storage:/var/lib/containers/storage \
95 + -v "$WORKDIR/source":/source \
96 + --entrypoint skopeo \
97 + "$BUILDER" \
98 + copy "containers-storage:$IMAGE" "oci-archive:/source/alloy.oci:alloy:local"
91 99 else
92 100 say "skipping the install source"
93 101 fi
@@ -105,6 +113,7 @@
105 113
106 114 say "assembling the ISO"
107 115 sudo podman run --rm --privileged \
116 + -e "ALLOY_ISO_FAST=$FAST" \
108 117 --security-opt label=type:unconfined_t \
109 118 -v "$ROOTFS":/rootfs:ro \
110 119 -v "$OUTPUT":/output \
M build/make-iso.sh +89 -18
@@ -46,15 +46,29 @@
46 46 SIZE_KB="$(du -sk "$ROOTFS" | cut -f1)"
47 47 # Slack for the ext4 metadata plus room for the overlay's early writes.
48 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"
49 + # The LiveOS directory has to exist *inside* the squashfs, so mksquashfs is
50 + # pointed at its parent: it takes the contents of the directory it is given
51 + # as the squashfs root, so compressing LiveOS/ directly puts rootfs.img at
52 + # the top level and dracut fails with "Failed to find a root filesystem in
53 + # .../squashfs.img" while looking for LiveOS/rootfs.img.
54 + mkdir -p "$WORK/sqroot/LiveOS"
55 + truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img"
56 + mkfs.ext4 -q -L Alloy -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"
52 57
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 + # zstd level 19 costs minutes of saturated CPU for a few percent of size,
59 + # which is worth it for a release and pure waste when the question is
60 + # whether the thing boots at all. ALLOY_ISO_FAST picks the cheap level.
61 + if [ "${ALLOY_ISO_FAST:-0}" = "1" ]; then
62 + COMP_LEVEL=3
63 + say "compressing squashfs.img (fast mode, level $COMP_LEVEL)"
64 + else
65 + COMP_LEVEL=19
66 + say "compressing squashfs.img (this is the long part)"
67 + fi
68 + mksquashfs "$WORK/sqroot" "$WORK/iso/LiveOS/squashfs.img" \
69 + -noappend -no-progress -comp zstd -Xcompression-level "$COMP_LEVEL" -b 1M \
70 + -processors "$(nproc)"
71 + rm -rf "$WORK/sqroot"
58 72
59 73 # ---------------------------------------------------------------------
60 74 # 2. Kernel and a live initramfs.
@@ -65,13 +79,39 @@
65 79 say "kernel and initramfs"
66 80 cp "$ROOTFS/usr/lib/modules/$KVER/vmlinuz" "$WORK/iso/boot/vmlinuz"
67 81
82 + # The image's own dracut config is for booting an installed ostree system:
83 + # /usr/lib/dracut/dracut.conf.d/*bootc* add the ostree and bootc modules,
84 + # which assume an ostree root and fail here trying to install /root. A live
85 + # medium has no deployment to find, so start from no distro config at all
86 + # (--conf /dev/null, empty --confdir) and name what this initramfs needs.
87 + mkdir -p "$WORK/empty-conf"
68 88 dracut --force --no-hostonly --nomdadmconf --nolvmconf \
89 + --conf /dev/null \
90 + --confdir "$WORK/empty-conf" \
69 91 --kver "$KVER" \
70 92 --kmoddir "$ROOTFS/usr/lib/modules/$KVER" \
71 93 --add "dmsquash-live" \
94 + --omit "ostree bootc" \
72 95 --add-drivers "squashfs loop overlay iso9660 sr_mod sd_mod usb_storage virtio_blk virtio_scsi virtio_pci" \
73 96 "$WORK/iso/boot/initramfs.img"
74 97
98 + # dracut reports module failures on stderr and still exits 0, so a broken
99 + # initramfs ships looking like a success. Check the result instead.
100 + #
101 + # Expect one error from this that does not matter: dracut-install cannot
102 + # install `/root`, because in the bootc layout /root is a symlink to
103 + # var/roothome and nothing has mounted a var here. dracut carries on and
104 + # the initramfs is complete.
105 + #
106 + # grep -c rather than grep -q: grep -q exits at the first match, which
107 + # SIGPIPEs lsinitrd, and under `set -o pipefail` that reads as a failed
108 + # check on a perfectly good initramfs. It cost a build to work that out.
109 + [ -s "$WORK/iso/boot/initramfs.img" ] || { echo "dracut produced no initramfs" >&2; exit 1; }
110 + found="$(lsinitrd "$WORK/iso/boot/initramfs.img" 2>/dev/null | grep -c dmsquash || true)"
111 + [ "${found:-0}" -gt 0 ] \
112 + || { echo "initramfs has no dmsquash-live; it cannot mount the live root" >&2; exit 1; }
113 + say "initramfs $(du -h "$WORK/iso/boot/initramfs.img" | cut -f1), dmsquash-live present"
114 +
75 115 # ---------------------------------------------------------------------
76 116 # 3. The image the installer deploys.
77 117 # ---------------------------------------------------------------------
@@ -92,7 +132,29 @@
92 132 # reads to find the squashfs.
93 133 # ---------------------------------------------------------------------
94 134 say "grub"
95 - CMDLINE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer quiet loglevel=3"
135 + # console= twice on purpose. The kernel writes to every console listed, so
136 + # serial gets a full log for headless debugging, while /dev/console resolves
137 + # to the last one named and so stays on the screen the user is looking at.
138 + # Reversed, the installer's own output would go down the serial line and the
139 + # monitor would sit black. The installer renders on tty1 explicitly
140 + # (alloy-installer.service, TTYPath) and does not depend on this ordering.
141 + CONSOLES="console=ttyS0,115200 console=tty0"
142 + # selinux=0 on the live medium.
143 + #
144 + # The live rootfs.img is built by mkfs.ext4 from a container rootfs, and it
145 + # carries SELinux labels only if the machine doing the building has SELinux
146 + # to derive them from. fw13 is Pop!_OS and has none, so every file in the
147 + # live root is unlabeled and systemd refuses to start at all: "Failed to
148 + # allocate manager object: Permission denied", then it freezes, with no
149 + # hint that labelling is what is missing.
150 + #
151 + # This is a real limitation of the ISO, not just of the live boot: an
152 + # installer that cannot label cannot label the target either. Building on a
153 + # Fedora host is what fixes it properly. Until then the ISO installs
154 + # systems that need `enforcing=0` to boot, which is recorded in the wiki
155 + # note alloy-build-notes.
156 + SELINUX_OFF="selinux=0"
157 + CMDLINE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES $SELINUX_OFF quiet loglevel=3"
96 158
97 159 cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF
98 160 set default=0
@@ -104,7 +166,14 @@
104 166 }
105 167
106 168 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
169 + linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES $SELINUX_OFF rd.debug rd.shell systemd.log_level=debug
170 + initrd /boot/initramfs.img
171 + }
172 +
173 + # Not a boot option: a rescue shell in the initramfs, for when the live root
174 + # will not mount at all and there is nothing else to ask.
175 + menuentry "Initramfs shell" {
176 + linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image $CONSOLES rd.break=pre-mount
108 177 initrd /boot/initramfs.img
109 178 }
110 179 EOF
@@ -118,17 +187,19 @@
118 187 configfile /boot/grub/grub.cfg
119 188 EOF
120 189
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 -
190 + # Our own GRUB *is* BOOTX64.EFI, with no shim in front of it.
191 + #
192 + # The signed shim and signed grubx64.efi that shim expects to chain to both
193 + # ship in RPMs whose files a bootc image strips out of /boot, and the
194 + # packages still read as installed, so reinstalling them is the only way to
195 + # get the bytes back. Not worth it here: a self-built GRUB is unsigned
196 + # either way, so this ISO needs Secure Boot off regardless of whether shim
197 + # is in front of it. Signing is a distribution problem and distribution is
198 + # not set up (GO task 0d7505b5); when it is, this is where shim goes.
128 199 grub2-mkimage \
129 200 --format=x86_64-efi \
130 201 --prefix="/EFI/BOOT" \
131 - --output="$WORK/iso/EFI/BOOT/grubx64.efi" \
202 + --output="$WORK/iso/EFI/BOOT/BOOTX64.EFI" \
132 203 part_gpt part_msdos fat iso9660 udf normal linux echo all_video test \
133 204 search search_label search_fs_uuid search_fs_file gfxterm gfxterm_background \
134 205 configfile loadenv chain efi_gop efi_uga ls cat halt reboot minicmd \