Skip to main content

max / alloy

33.0 KB · 667 lines History Blame Raw
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 layout 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 layout 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 # The build this medium carries, read out of the image it was made from, so a
26 # stick can be asked which mint it holds without booting it:
27 #
28 # xorriso -indev install.iso -pvd_info (or isoinfo -d, or `blkid`)
29 #
30 # It answers the question that otherwise costs an install. The ISO's creation
31 # timestamp is close but not the same thing: two mints of different trees can
32 # share a minute, and the stamp names the image.
33 #
34 # Empty is not an error. A hand-built image with no stamp is a real thing to
35 # have, and a medium made from one says so rather than refusing to be made.
36 STAMP="$(sed -n 's/^IMAGE_VERSION="\(.*\)"$/\1/p' "$ROOTFS/usr/lib/os-release" 2>/dev/null | head -1)"
37
38 # Every phase line carries the seconds since the last one, because the ISO is
39 # where this build spends most of its wall clock and nobody could say on what.
40 # The four minutes were assumed to be compression until they were measured;
41 # they were not (mksquashfs at the fast level is 8s of it). A build that
42 # reports its own shape is the difference between optimizing the slow part and
43 # optimizing the part that looks slow.
44 #
45 # The number on a line is the time spent getting TO it, not the time the phase
46 # it announces will take, because a phase cannot report its own cost before
47 # doing the work. Read a line's figure as the price of the line above it.
48 START_MARK=$(date +%s)
49 LAST_MARK=$START_MARK
50 say() {
51 local now elapsed
52 now=$(date +%s)
53 elapsed=$((now - LAST_MARK))
54 LAST_MARK=$now
55 printf '==> [+%3ds] %s\n' "$elapsed" "$*"
56 }
57
58 [ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; }
59 [ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; }
60
61 # ---------------------------------------------------------------------
62 # 0. Which architecture this medium is for.
63 #
64 # From `uname -m`, because the builder image is derived from the Alloy image
65 # and carries its kernel: the squashfs, the initramfs and the OCI layout are
66 # all already the architecture this is running on, and an ISO whose boot
67 # chain disagreed with them would boot nothing. ALLOY_ISO_ARCH overrides it
68 # for testing the GRUB half alone; it does not make this a cross-build.
69 #
70 # Anything but the two mappings is an explicit refusal. Falling through to a
71 # default would produce an ISO with the wrong EFI binary name, which fails
72 # as "the stick is not bootable" with no diagnosis available on the machine
73 # that cannot boot it.
74 # ---------------------------------------------------------------------
75 ARCH="${ALLOY_ISO_ARCH:-$(uname -m)}"
76 case "$ARCH" in
77 x86_64)
78 GRUB_FORMAT="x86_64-efi"
79 # The removable-media path from the UEFI spec. Firmware looks for this
80 # exact name on a medium with no boot entry of its own and nothing else,
81 # so it is per-architecture and is not ours to choose.
82 EFI_NAME="BOOTX64.EFI"
83 GRUB_MODULE_PKG="grub2-efi-x64-modules"
84 SERIAL_CONSOLES="console=ttyS0,115200" ;;
85 aarch64)
86 GRUB_FORMAT="arm64-efi"
87 EFI_NAME="BOOTAA64.EFI"
88 GRUB_MODULE_PKG="grub2-efi-aa64-modules"
89 # ttyAMA0 first because SBSA mandates a PL011 and that is what an Ampere
90 # board actually presents; ttyS0 stays listed for boards that present an
91 # 8250 instead. The kernel writes to every console that exists and
92 # ignores the ones that do not, so listing both costs nothing and losing
93 # the serial log on a headless arm box costs the only view of a failed
94 # boot. tty0 stays last, so /dev/console is still the screen.
95 SERIAL_CONSOLES="console=ttyAMA0,115200 console=ttyS0,115200" ;;
96 *)
97 echo "unsupported architecture: $ARCH (this builds x86_64 and aarch64)" >&2
98 exit 1 ;;
99 esac
100
101 # grub2-mkimage reads the modules for the target format out of this
102 # directory, and says only "cannot open directory" when they are absent.
103 # Name the package instead, before the several minutes of squashfs that
104 # would otherwise run first.
105 [ -d "/usr/lib/grub/$GRUB_FORMAT" ] \
106 || { echo "no GRUB modules for $GRUB_FORMAT; the builder needs $GRUB_MODULE_PKG" >&2; exit 1; }
107
108 # The modules the EFI binary is built from, checked here for the same reason:
109 # grub2-mkimage runs at the end and stops at the first module it cannot open,
110 # so an absent one costs a whole build to discover and names only itself.
111 #
112 # The list is *almost* architecture-independent. Measured on astra 2026-08-06 by
113 # building an arm ISO rather than assumed: of the 30 names, arm64-efi carries
114 # 29. The exception is `efi_uga`, and its absence is correct. UGA is EFI 1.x's
115 # pre-GOP graphics protocol and only ever existed on x86 firmware; arm64 UEFI
116 # has always had GOP, and `efi_gop` is in the list and present on both. So the
117 # module is dropped on arm rather than substituted for.
118 #
119 # One list with a conditional addition, not two lists: a second copy of 29
120 # shared names drifts the day anything is added to one of them.
121 MODULES="part_gpt part_msdos fat iso9660 udf normal linux echo all_video test
122 search search_label search_fs_uuid search_fs_file gfxterm gfxterm_background
123 configfile loadenv chain efi_gop ls cat halt reboot minicmd
124 font terminal squash4 loopback probe regexp"
125 [ "$ARCH" = "x86_64" ] && MODULES="$MODULES efi_uga"
126
127 missing=""
128 for module in $MODULES; do
129 [ -f "/usr/lib/grub/$GRUB_FORMAT/$module.mod" ] || missing="$missing $module"
130 done
131 [ -z "$missing" ] || {
132 echo "the $GRUB_FORMAT target is missing:$missing" >&2
133 echo "either $GRUB_MODULE_PKG is incomplete, or the module does not exist on this architecture and this list needs another exception" >&2
134 exit 1
135 }
136
137 say "architecture $ARCH, $GRUB_FORMAT, $EFI_NAME"
138
139 # Version-sorted, not `head -1`: `ls` collates 7.1.10 before 7.1.3, so the
140 # alphabetical pick is arbitrary rather than newest. A bootc image should carry
141 # exactly one kernel, so more than one means something layered a second and the
142 # choice stops being obvious, so say so rather than picking silently.
143 KVER="$(ls -1 "$ROOTFS/usr/lib/modules" | sort -V | tail -1)"
144 [ -n "$KVER" ] || { echo "no kernel in $ROOTFS/usr/lib/modules" >&2; exit 1; }
145 KCOUNT="$(ls -1 "$ROOTFS/usr/lib/modules" | wc -l)"
146 [ "$KCOUNT" -eq 1 ] || echo "warning: $KCOUNT kernels present, using newest ($KVER)" >&2
147 say "kernel $KVER"
148
149 rm -rf "$WORK"
150 mkdir -p "$WORK/iso/LiveOS" "$WORK/iso/EFI/BOOT" "$WORK/iso/boot/grub"
151
152 # ---------------------------------------------------------------------
153 # 1. The live root, as a squashfs holding an ext4 image.
154 #
155 # dmsquash-live expects LiveOS/squashfs.img to contain LiveOS/rootfs.img,
156 # not the root tree directly. Handing it a squashfs of the tree boots to a
157 # dracut shell with "failed to mount live root", which says nothing about
158 # the layout being the problem.
159 # ---------------------------------------------------------------------
160 say "building rootfs.img"
161 SIZE_KB="$(du -sk "$ROOTFS" | cut -f1)"
162 # Slack for ext4 metadata. Not for the live session's writes: with
163 # rd.live.overlay.overlayfs=1 this image is the overlay's read-only lower dir
164 # and is never written to. The upper dir is a tmpfs under /run, so the real
165 # budget for the live session is RAM, not this number.
166 IMG_MB=$(( SIZE_KB / 1024 + 1536 ))
167 # Inodes are budgeted from the file count rather than left to the default
168 # bytes-per-inode ratio. A desktop rootfs is many small files, and the default
169 # runs out before the space does. That fails mkfs mid-populate, minutes into
170 # a build, with an error that reads as unrelated to file count.
171 INODES=$(( $(find "$ROOTFS" -xdev 2>/dev/null | wc -l) * 12 / 10 ))
172 say "rootfs.img ${IMG_MB}M, $INODES inodes"
173 # The LiveOS directory has to exist *inside* the squashfs, so mksquashfs is
174 # pointed at its parent: it takes the contents of the directory it is given
175 # as the squashfs root, so compressing LiveOS/ directly puts rootfs.img at
176 # the top level and dracut fails with "Failed to find a root filesystem in
177 # .../squashfs.img" while looking for LiveOS/rootfs.img.
178 mkdir -p "$WORK/sqroot/LiveOS"
179 truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img"
180 mkfs.ext4 -q -L Alloy -N "$INODES" -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"
181
182 # The console, into the live root and nowhere else.
183 #
184 # The image deliberately does not carry /usr/bin/alloy. A component the base
185 # carries can never be replaced client-side, so the console and shop travel as
186 # uninstalled RPMs at /usr/share/alloy/rpm and are layered on the first boot
187 # after an install (Containerfile, "The component repo"; measurements in
188 # build/layertest).
189 #
190 # The live environment is the one place that cannot wait for a first boot. It
191 # never gets one, it is thrown away when the install finishes, and `alloy
192 # install` is the entire reason the medium exists: alloy-installer.service and
193 # the installer account's ForceCommand both name /usr/bin/alloy. Without this
194 # the ISO boots to a machine with no installer, which is how it behaved for
195 # exactly one build.
196 #
197 # It goes into the mounted image rather than into $ROOTFS, which is the
198 # read-only mount of the shipped image and must stay exactly what installs.
199 # `bootc install` reads the OCI layout under /source, not this tree, so the
200 # console lands in the live session and reaches no installed machine.
201 #
202 # From the carried repo rather than by copying a binary in, and the difference
203 # is what it proves: this is the first thing that ever resolves against
204 # /usr/share/alloy/rpm, so a package that cannot install fails here, on a build
205 # host, instead of at somebody's first boot where the symptom is a session that
206 # will not start. rpm rather than dnf because the repo is on the read-only tree
207 # and the dependencies are already in the base, so nothing needs resolving.
208 say "installing the console into the live root"
209 mkdir -p "$WORK/liveroot"
210 mount -o loop "$WORK/sqroot/LiveOS/rootfs.img" "$WORK/liveroot"
211 set -- "$ROOTFS"/usr/share/alloy/rpm/alloy-*.rpm
212 [ "$#" -eq 1 ] && [ -f "$1" ] \
213 || { echo "expected one console RPM in the carried repo, got: $*" >&2; umount "$WORK/liveroot"; exit 1; }
214 rpm --root "$WORK/liveroot" -i "$1" \
215 || { echo "the console RPM did not install into the live root" >&2; umount "$WORK/liveroot"; exit 1; }
216 test -x "$WORK/liveroot/usr/bin/alloy" \
217 || { echo "no /usr/bin/alloy in the live root; the ISO would boot with no installer" >&2; umount "$WORK/liveroot"; exit 1; }
218 umount "$WORK/liveroot"
219 rmdir "$WORK/liveroot"
220
221 # ALLOY_ISO_FAST picks the cheap level, for when the question is whether the
222 # thing boots at all rather than how big it is.
223 #
224 # Measured on fw13 (12 cores) against this image, 2026-08-15, 6.4G of rootfs:
225 #
226 # level time squashfs.img
227 # 3 8s 2.519 G
228 # 9 20s 2.412 G
229 # 12 29s 2.405 G
230 # 15 63s 2.398 G
231 # 19 192s 2.264 G
232 #
233 # Two things in that table, and both contradict what this comment used to say.
234 # It claimed level 19 costs "about ten minutes of saturated CPU for a few
235 # percent of size"; it costs three, and it is the only level that buys anything
236 # after 9 — the jump at 19 is zstd's long-distance matching, which the levels
237 # below it do not enable. And 12 and 15 are dominated: 15 spends 43 seconds
238 # more than 9 to save 14 MB.
239 #
240 # So the two levels here are the two worth having. 19 for a release, where
241 # three minutes is nothing against 255 MB off every stick and every install.
242 # 3 for iteration, where eight seconds is close enough to free.
243 if [ "${ALLOY_ISO_FAST:-0}" = "1" ]; then
244 COMP_LEVEL=3
245 say "compressing squashfs.img (fast mode, level $COMP_LEVEL)"
246 else
247 COMP_LEVEL=19
248 say "compressing squashfs.img (this is the long part)"
249 fi
250 mksquashfs "$WORK/sqroot" "$WORK/iso/LiveOS/squashfs.img" \
251 -noappend -no-progress -comp zstd -Xcompression-level "$COMP_LEVEL" -b 1M \
252 -processors "$(nproc)"
253 rm -rf "$WORK/sqroot"
254
255 # ---------------------------------------------------------------------
256 # 2. Kernel and a live initramfs.
257 #
258 # --no-hostonly matters: a host-only initramfs is built for the hardware
259 # doing the building, and this one has to boot anything.
260 # ---------------------------------------------------------------------
261 say "kernel and initramfs"
262 cp "$ROOTFS/usr/lib/modules/$KVER/vmlinuz" "$WORK/iso/boot/vmlinuz"
263
264 # The image's own dracut config is for booting an installed ostree system:
265 # /usr/lib/dracut/dracut.conf.d/*bootc* add the ostree and bootc modules,
266 # which assume an ostree root and fail here trying to install /root. A live
267 # medium has no deployment to find, so start from no distro config at all
268 # (--conf /dev/null, empty --confdir) and name what this initramfs needs.
269 mkdir -p "$WORK/empty-conf"
270 dracut --force --no-hostonly --nomdadmconf --nolvmconf \
271 --conf /dev/null \
272 --confdir "$WORK/empty-conf" \
273 --kver "$KVER" \
274 --kmoddir "$ROOTFS/usr/lib/modules/$KVER" \
275 --add "dmsquash-live" \
276 --omit "ostree bootc" \
277 --add-drivers "squashfs loop overlay iso9660 sr_mod sd_mod usb_storage virtio_blk virtio_scsi virtio_pci" \
278 "$WORK/iso/boot/initramfs.img" 2>&1 | tee "$WORK/dracut.log"
279
280 # dracut reports module failures on stderr and still exits 0, so a broken
281 # initramfs ships looking like a success. Check the result instead.
282 #
283 # This build used to print `ERROR: installing '/root'` and a FAILED line
284 # every time, harmlessly: /root is a symlink to var/roothome in the bootc
285 # layout and no var is populated in a container. The builder now creates
286 # /var/roothome (see build/Containerfile.iso), so that error is gone rather
287 # than tolerated, and this check can be the strict one it could not be
288 # while a known error was expected in normal output.
289 #
290 # One message is still allowlisted, by its exact text. dracut tests for
291 # /dev/log or a logger binary before it installs anything and warns when it
292 # finds neither; `--install logger` does not satisfy it, because the test
293 # runs first. It concerns logging from inside the initramfs and nothing
294 # else. Matching the whole sentence rather than a pattern keeps the
295 # allowance from widening into "errors mentioning logging are fine".
296 #
297 # grep -c rather than grep -q: grep -q exits at the first match, which
298 # SIGPIPEs lsinitrd, and under `set -o pipefail` that reads as a failed
299 # check on a perfectly good initramfs. It cost a build to work that out.
300 [ -s "$WORK/iso/boot/initramfs.img" ] || { echo "dracut produced no initramfs" >&2; exit 1; }
301 unexpected="$(grep -E 'dracut\[E\]|dracut-install: ERROR|FAILED' "$WORK/dracut.log" \
302 | grep -vF "No '/dev/log' or 'logger' included for syslog logging" || true)"
303 if [ -n "$unexpected" ]; then
304 echo "dracut reported errors this build does not expect:" >&2
305 printf '%s\n' "$unexpected" | sed 's/^/ /' >&2
306 exit 1
307 fi
308 found="$(lsinitrd "$WORK/iso/boot/initramfs.img" 2>/dev/null | grep -c dmsquash || true)"
309 [ "${found:-0}" -gt 0 ] \
310 || { echo "initramfs has no dmsquash-live; it cannot mount the live root" >&2; exit 1; }
311 say "initramfs $(du -h "$WORK/iso/boot/initramfs.img" | cut -f1), dmsquash-live present"
312
313 # ---------------------------------------------------------------------
314 # 3. The image the installer deploys.
315 # ---------------------------------------------------------------------
316 if [ -d "$SOURCE" ] && [ -n "$(ls -A "$SOURCE" 2>/dev/null)" ]; then
317 say "copying the install source onto the ISO"
318 mkdir -p "$WORK/iso/source"
319 cp -a "$SOURCE/." "$WORK/iso/source/"
320 else
321 echo "warning: no OCI layout at $SOURCE; the ISO will have nothing to install" >&2
322 fi
323
324 # ---------------------------------------------------------------------
325 # 4. GRUB.
326 #
327 # `alloy.installer` is what arms alloy-installer.service; without it the
328 # live system boots to a greeter with no account, which looks like a
329 # broken ISO. rd.live.image and root=live:CDLABEL are what dmsquash-live
330 # reads to find the squashfs.
331 # ---------------------------------------------------------------------
332 say "grub"
333 # console= more than once on purpose. The kernel writes to every console
334 # listed, so serial gets a full log for headless debugging, while
335 # /dev/console resolves to the last one named and so stays on the screen the
336 # user is looking at. Which serial lines are named is per-architecture; see
337 # the arch block at the top.
338 # Reversed, the installer's own output would go down the serial line and the
339 # monitor would sit black. The installer renders on tty1 explicitly
340 # (alloy-installer.service, TTYPath) and does not depend on this ordering.
341 CONSOLES="$SERIAL_CONSOLES console=tty0"
342 # SELinux on the live medium: permissive by default, off as a fallback.
343 #
344 # The live rootfs.img is built by mkfs.ext4 from a container rootfs, and it
345 # carries SELinux labels only if the machine doing the building has SELinux
346 # to derive them from. fw13 is Pop!_OS and has none, so every file in the
347 # live root is unlabeled and systemd refuses to start at all: "Failed to
348 # allocate manager object: Permission denied", then it freezes, with no
349 # hint that labelling is what is missing.
350 #
351 # `selinux=0` cures the freeze and costs the install. A process running
352 # with SELinux switched off cannot write labels to anything, the target
353 # disk included, so the machine this ISO installs comes up unlabeled and
354 # freezes the same way on its own first boot, with no way past it but
355 # editing the kernel line at GRUB by hand.
356 #
357 # `enforcing=0` is the setting between the two: the policy in the image at
358 # /etc/selinux/targeted/policy loads, unlabeled files stop being fatal, and
359 # the kernel still tracks labels. That is enough for the live system to
360 # boot and, unlike selinux=0, leaves `bootc install` able to label the
361 # target as it writes it. An install from the permissive entry should come
362 # up enforcing and correct on its first boot, with no relabel pass and no
363 # manual step.
364 #
365 # Should, because it is untested until this ISO boots on real hardware,
366 # which is why both entries ship. Boot the default; if the live system
367 # freezes before the installer draws, the SELinux-off entry is exactly the
368 # medium that was known to work, at the cost of an install that needs
369 # `enforcing=0` typed at GRUB on first boot. Recorded in the wiki note
370 # alloy-build-notes.
371 #
372 # Building on a Fedora host removes the question entirely by shipping a
373 # labeled live root. That is a build-host project, not a boot flag.
374 SELINUX_PERMISSIVE="enforcing=0"
375 SELINUX_OFF="selinux=0"
376 LIVE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES"
377
378 # Where machines installed from this ISO will fetch their updates.
379 #
380 # Empty for ordinary media, which leaves the installer on its compiled-in
381 # default (the public registry). Set by `build/build-iso.sh --update-target`
382 # for media built against a registry that exists today, so the machines it
383 # installs can take an update without a `bootc switch` afterwards.
384 #
385 # Whitespace would split into a second kernel parameter and silently truncate
386 # the reference, so it is refused rather than quoted: a reference cannot
387 # legally contain any.
388 if [ -n "${ALLOY_UPDATE_TARGET:-}" ]; then
389 case "$ALLOY_UPDATE_TARGET" in
390 *[[:space:]]*)
391 echo "update target contains whitespace: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
392 esac
393 # A tag is required, because the containers stack reads its absence as
394 # `latest` and docs/IMAGE.md makes a major bump a deliberate act. The tag is
395 # what follows the last colon, and only when no `/` follows it: a colon
396 # before a slash is a registry port, not a tag.
397 case "${ALLOY_UPDATE_TARGET##*:}" in
398 */*|"$ALLOY_UPDATE_TARGET")
399 echo "update target has no tag: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
400 latest)
401 echo "update target is :latest; pin a version" >&2; exit 1 ;;
402 esac
403 say "update target $ALLOY_UPDATE_TARGET"
404 LIVE="$LIVE alloy.update-target=$ALLOY_UPDATE_TARGET"
405 fi
406 CMDLINE="$LIVE $SELINUX_PERMISSIVE quiet loglevel=3"
407
408 cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF
409 set default=0
410 set timeout=5
411
412 menuentry "Install Alloy" {
413 linux /boot/vmlinuz $CMDLINE
414 initrd /boot/initramfs.img
415 }
416
417 # The fallback, for a live system that freezes before the installer draws.
418 # Installs a machine that needs enforcing=0 on its own first boot; see the
419 # SELinux note above.
420 menuentry "Install Alloy (SELinux off, if the default will not boot)" {
421 linux /boot/vmlinuz $LIVE $SELINUX_OFF quiet loglevel=3
422 initrd /boot/initramfs.img
423 }
424
425 # alloy.debug arms alloy-debug-shell@.service, which puts a root shell on tty9
426 # and on the serial console. The live medium has no account and a locked root,
427 # so without this a failure inside the live system can only be read off
428 # whatever the installer printed before it stopped.
429 #
430 # Quiet, like the default. This entry used to add rd.debug and
431 # systemd.log_level=debug, which with console=tty0 print over the installer TUI
432 # and over the shell itself. Every question asked of the live system so far has
433 # been answered from the shell, with dmesg and findmnt, which hold the same
434 # information and hold still while being read.
435 menuentry "Install Alloy (root shell on tty9)" {
436 linux /boot/vmlinuz $CMDLINE alloy.debug
437 initrd /boot/initramfs.img
438 }
439
440 # Not a boot option: a rescue shell in the initramfs, for when the live root
441 # will not mount at all and there is nothing else to ask.
442 menuentry "Initramfs shell" {
443 linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image $CONSOLES rd.break=pre-mount
444 initrd /boot/initramfs.img
445 }
446 EOF
447
448 # grub.cfg next to the EFI binary is the one the firmware reads first; it
449 # hands off to the copy above so there is a single place to edit entries.
450 mkdir -p "$WORK/iso/EFI/BOOT"
451 cat > "$WORK/iso/EFI/BOOT/grub.cfg" <<EOF
452 search --no-floppy --set=root --label $VOLID
453 set prefix=(\$root)/boot/grub
454 configfile /boot/grub/grub.cfg
455 EOF
456
457 # Our own GRUB *is* the removable-media binary, with no shim in front of it.
458 #
459 # The signed shim and signed grubx64.efi that shim expects to chain to both
460 # ship in RPMs whose files a bootc image strips out of /boot, and the
461 # packages still read as installed, so reinstalling them is the only way to
462 # get the bytes back. Not worth it here: a self-built GRUB is unsigned
463 # either way, so this ISO needs Secure Boot off regardless of whether shim
464 # is in front of it. Signing is a distribution problem and distribution is
465 # not set up (GO task 0d7505b5); when it is, this is where shim goes.
466 #
467 # The module list is *almost* architecture-independent. Measured on astra
468 # 2026-08-06 by building an arm ISO, rather than assumed: of the 30 modules
469 # below, arm64-efi carries 29. The exception is `efi_uga`, and its absence is
470 # correct. UGA is EFI 1.x's pre-GOP graphics protocol, which only ever existed
471 # on x86 firmware; arm64 UEFI has always had GOP, and `efi_gop` is in the list
472 # and present. So the module is dropped on arm rather than replaced.
473 #
474 # The list itself is built at the top of this file, and checked there, so a
475 # missing module fails before the several minutes of squashfs rather than after.
476 #
477 # shellcheck disable=SC2086 # $MODULES is a deliberate word-split list
478 grub2-mkimage \
479 --format="$GRUB_FORMAT" \
480 --prefix="/EFI/BOOT" \
481 --output="$WORK/iso/EFI/BOOT/$EFI_NAME" \
482 $MODULES
483
484 # The EFI system partition the firmware actually mounts. Sized to contents
485 # rather than a round number, because a fixed size is a future failure the
486 # day the EFI binaries grow.
487 say "efiboot.img"
488 # FAT16, not whatever mkfs.fat picks. Sized to contents it picks FAT12, which
489 # UEFI permits on removable media but which no shipping distro relies on --
490 # Arch and Fedora both force FAT16 for exactly this image. Firmware that
491 # declines FAT12 produces no boot option at all, and the GPT still looks
492 # perfect to every tool you would inspect it with, so the failure arrives as
493 # "the stick is not bootable" with nothing to read.
494 #
495 # FAT16 needs at least 4085 clusters, which the contents-sized image is far
496 # below, so floor it at 16 MiB. That is noise against a 4.8 GB ISO.
497 EFI_KB=$(( $(du -sk "$WORK/iso/EFI" | cut -f1) + 2048 ))
498 [ "$EFI_KB" -lt 16384 ] && EFI_KB=16384
499 truncate -s "${EFI_KB}K" "$WORK/efiboot.img"
500 # The label some firmware shows for a removable boot entry, which is the only
501 # string on this medium with any chance of reaching a boot menu. EDK2 does not
502 # read it -- measured 2026-09-07 under OVMF, which named our medium
503 # `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` from the USB descriptor alone
504 # -- but several vendor firmwares do, so it is worth being a name rather than an
505 # identifier. Eleven characters is the FAT limit and this is ten.
506 #
507 # It must NOT equal $VOLID. GRUB's `search --label $VOLID` looks at every
508 # filesystem it can see, the ESP on this same medium included, so two
509 # filesystems sharing a label is a coin flip over which one becomes $root.
510 mkfs.fat -F 16 -n "ALLOY INST" "$WORK/efiboot.img" >/dev/null
511
512 # One code path, and it is allowed to fail.
513 #
514 # This used to try mmd/mcopy, fall back to a loop mount when mtools was
515 # absent, and then re-run mcopy under `|| true`. Three paths, of which one --
516 # mmd succeeding and mcopy failing, produced an ESP holding two empty
517 # directories and no bootloader, reported success, and shipped. mtools was
518 # not in the builder, so the loop-mount branch is what actually ran; the
519 # silent branch was live and simply never taken. mtools is installed now
520 # (build/Containerfile.iso) and this is the only path.
521 mmd -i "$WORK/efiboot.img" ::/EFI ::/EFI/BOOT
522 mcopy -i "$WORK/efiboot.img" -s "$WORK/iso/EFI/BOOT/"* ::/EFI/BOOT/
523
524 # Read it back out of the filesystem rather than trusting the exit codes: a
525 # bootloader that is not in here is the one defect this whole file exists to
526 # avoid, and it is invisible everywhere else.
527 #
528 # Checked against the name this build wrote, not a fixed one: an arm ISO
529 # that failed a check naming BOOTX64 would report an architecture it was
530 # never building for.
531 mdir -i "$WORK/efiboot.img" ::/EFI/BOOT | grep -qi "${EFI_NAME%.EFI}" \
532 || { echo "$EFI_NAME is not in the ESP; the medium cannot boot" >&2; exit 1; }
533 say "ESP $(( EFI_KB / 1024 ))M FAT16, $EFI_NAME present"
534 # Deliberately not copied into $WORK/iso. It is appended to the medium as a
535 # real partition below, and El Torito is pointed at that partition rather
536 # than at a file in the tree, so a copy inside the ISO filesystem would be
537 # a second copy of these same bytes that nothing reads.
538
539 # ---------------------------------------------------------------------
540 # 5. Assemble. UEFI only, matching the images bootc produces: they have no
541 # legacy BIOS path, so the MBR must be protective and must not advertise
542 # a BIOS boot that would fail.
543 # ---------------------------------------------------------------------
544 say "xorriso"
545 # -rock is not decoration here. An OCI layout stores every blob under a
546 # 64-character hex filename, and plain ISO 9660 truncates names to 31, so
547 # without Rock Ridge the layout arrives on the medium renamed and the
548 # install fails looking like a corrupt image rather than a naming problem.
549 # The squashfs and the boot files have short names and never needed it,
550 # which is why it was absent until the install source became a directory.
551 #
552 # The partition table is written by -append_partition + -appended_part_as_gpt,
553 # not by -isohybrid-gpt-basdat, which is what this call used to pass. That
554 # option is a no-op in this shape: it describes an El Torito image that was
555 # opened with -eltorito-alt-boot and there is no primary entry for it to be
556 # an alternative to, so xorriso emitted no table and reported no error. The
557 # result was an ISO whose first 4096 bytes were zero, i.e. no MBR, no GPT
558 # and no discoverable ESP, which left booting a USB stick up to how lenient
559 # the firmware felt. It survived because every QEMU test attached the ISO
560 # with -cdrom, and optical emulation boots El Torito by design and never
561 # looks for a partition table.
562 #
563 # Appending the ESP instead gives the firmware the removable-media path it
564 # actually specifies: a protective 0xEE MBR, a GPT, and the ESP as a typed
565 # partition. `-e --interval:appended_partition_2:all::` keeps El Torito as
566 # well, pointed at those same bytes, so the optical path is unchanged.
567 #
568 # fdisk reports three partitions, not two. The third is the 300 KiB of
569 # padding xorriso appends by default to work around kernels that read past
570 # the end of the medium; -appended_part_as_gpt describes every region of
571 # the image, padding included. It is expected, not a stray partition.
572 xorriso -as mkisofs \
573 -iso-level 3 \
574 -rock \
575 -volid "$VOLID" \
576 -appid "Alloy Installer" \
577 -publisher "Make Creative, LLC" \
578 -preparer "Alloy build/make-iso.sh" \
579 -sysid "LINUX" \
580 -volset "${STAMP:-unstamped}" \
581 -append_partition 2 C12A7328-F81F-11D2-BA4B-00A0C93EC93B "$WORK/efiboot.img" \
582 -appended_part_as_gpt \
583 -eltorito-alt-boot \
584 -e --interval:appended_partition_2:all:: \
585 -no-emul-boot \
586 -output "$OUTPUT/install.iso" \
587 "$WORK/iso"
588
589 # ---------------------------------------------------------------------
590 # 6. Read the boot structures back.
591 #
592 # The bug above shipped because the build trusted a flag that exited 0.
593 # These checks read the bytes the firmware reads, so a table that silently
594 # fails to appear fails the build instead of the stick.
595 # ---------------------------------------------------------------------
596 say "checking the partition table"
597 # -v is load-bearing, not decoration. Without it od replaces runs of identical
598 # 16-byte lines with a single `*`, so the hex string is shorter than the bytes
599 # it describes and every offset past the first repeat is wrong. A GPT entry
600 # array is mostly zero padding, which is exactly the input that triggers it.
601 # Presence checks survive this; arithmetic on offsets does not.
602 at() { dd if="$OUTPUT/install.iso" bs=1 skip="$1" count="$2" 2>/dev/null | od -An -tx1 -v | tr -d ' \n'; }
603
604 [ "$(at 510 2)" = "55aa" ] \
605 || { echo "no MBR signature at offset 510; the ISO has no partition table" >&2; exit 1; }
606 [ "$(at 450 1)" = "ee" ] \
607 || { echo "MBR partition 1 is not type 0xEE; the MBR is not protective" >&2; exit 1; }
608 [ "$(at 446 1)" = "00" ] \
609 || { echo "MBR partition 1 is marked bootable; this image has no BIOS path" >&2; exit 1; }
610 [ "$(at 462 16)" = "$(printf '00%.0s' $(seq 16))" ] \
611 || { echo "MBR partition 2 is populated; only a protective entry belongs here" >&2; exit 1; }
612 [ "$(dd if="$OUTPUT/install.iso" bs=512 skip=1 count=1 2>/dev/null | head -c 8)" = "EFI PART" ] \
613 || { echo "no GPT header at LBA 1" >&2; exit 1; }
614
615 # Every check above passes on a GPT that describes no ESP at all, which is the
616 # one thing this shape exists to add, so read the entry array and find it. The
617 # type GUID is stored mixed-endian: the first three fields little-endian, the
618 # last two as written, so C12A7328-F81F-11D2-BA4B-00A0C93EC93B lands on disk as
619 # the bytes below. A 16-byte substring match is not entry-aligned, but a GUID
620 # occurring by chance anywhere else in the array is not a thing that happens.
621 le() { local h=$1 out="" i; for ((i = ${#h} / 2 - 1; i >= 0; i--)); do out="$out${h:$((i * 2)):2}"; done; printf '%d\n' "0x$out"; }
622 ENTRY_LBA=$(le "$(at $((512 + 72)) 8)")
623 ENTRY_COUNT=$(le "$(at $((512 + 80)) 4)")
624 ENTRY_SIZE=$(le "$(at $((512 + 84)) 4)")
625 ARRAY=$(dd if="$OUTPUT/install.iso" bs=512 skip="$ENTRY_LBA" \
626 count=$(( (ENTRY_COUNT * ENTRY_SIZE + 511) / 512 )) 2>/dev/null | od -An -tx1 -v | tr -d ' \n')
627 case "$ARRAY" in
628 *28732ac11ff8d211ba4b00a0c93ec93b*) ;;
629 *) echo "the GPT describes no EFI system partition" >&2; exit 1 ;;
630 esac
631 say "protective MBR, GPT at LBA 1, ESP appended as partition 2"
632
633 # The checks above all pass on an image whose El Torito entry points somewhere
634 # other than the ESP, because they never compare the two. That is the optical boot
635 # path, and it is the one a partition-table change can silently break while
636 # every GPT check stays green. Read both numbers and require them to agree.
637 #
638 # The GUID match above found the ESP somewhere in the entry array; recover
639 # which entry it was, so its start LBA can be read rather than assumed.
640 ESP_PREFIX="${ARRAY%%28732ac11ff8d211ba4b00a0c93ec93b*}"
641 ESP_INDEX=$(( ${#ESP_PREFIX} / 2 / ENTRY_SIZE ))
642 ESP_LBA=$(le "${ARRAY:$(( (ESP_INDEX * ENTRY_SIZE + 32) * 2 )):16}")
643
644 # El Torito: the boot record volume descriptor at LBA 17 points at the boot
645 # catalog; the default entry sits 32 bytes into it, with the sector count at
646 # +6 and the load RBA at +8. The RBA is in 2048-byte blocks, the partition in
647 # 512-byte sectors.
648 CAT_LBA=$(le "$(at $((17 * 2048 + 71)) 4)")
649 ENTRY_OFF=$(( CAT_LBA * 2048 + 32 ))
650 BOOT_COUNT=$(le "$(at $((ENTRY_OFF + 6)) 2)")
651 BOOT_RBA=$(le "$(at $((ENTRY_OFF + 8)) 4)")
652
653 [ "$(( BOOT_RBA * 2048 ))" = "$(( ESP_LBA * 512 ))" ] \
654 || { echo "El Torito load RBA ($(( BOOT_RBA * 2048 ))) is not the ESP ($(( ESP_LBA * 512 ))); optical boot would read the wrong bytes" >&2; exit 1; }
655
656 ISO_BYTES=$(stat -c %s "$OUTPUT/install.iso")
657 [ "$(( BOOT_RBA * 2048 + BOOT_COUNT * 512 ))" -le "$ISO_BYTES" ] \
658 || { echo "El Torito extent runs past the end of the image" >&2; exit 1; }
659
660 say "El Torito entry points at the ESP, $BOOT_COUNT sectors, within the image"
661
662 chmod 0644 "$OUTPUT/install.iso"
663 say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso"
664
665 TOTAL=$(( $(date +%s) - START_MARK ))
666 printf '==> total %dm%02ds\n' "$((TOTAL / 60))" "$((TOTAL % 60))"
667