Skip to main content

max / alloy

16.5 KB · 346 lines History Blame Raw
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/ 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 layout (faster; the
25 # # ISO boots but cannot install)
26 # build/build-iso.sh --fast # iteration: cheap compression, keeps source
27 # build/build-iso.sh --no-preflight # mint a knowingly incomplete image
28 # build/build-iso.sh --fast --skip-source # boot chain only, cannot install
29 # build/build-iso.sh --update-target host:5000/alloy:43 # updates come from there
30 # # (or UPDATE_TARGET in the host recipe)
31 # build/build-iso.sh --host astra # mint with astra's recipe, arch included
32 # build/build-iso.sh --arch aarch64 # override the detected architecture
33 # build/build-iso.sh --nvidia # install the derived alloy-nvidia image
34 # build/build-iso.sh --no-nvidia # ...even if the recipe asks for it
35 # build/build-iso.sh --write /dev/sdX # build, then write to a device
36 # build/build-iso.sh --write-only --write /dev/sdX # write the ISO already built
37 #
38 # Writing is build/write-device.sh's job, shared with build/build-image.sh,
39 # so there is one implementation of the dd path and one set of guards.
40
41 set -euo pipefail
42
43 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
44
45 # priv / privc. run0 where it exists, sudo where it does not; see the header
46 # of build/privilege.sh for which of the two a call site wants.
47 # shellcheck source=build/privilege.sh
48 . "$REPO_ROOT/build/privilege.sh"
49
50 # stamp_build_args. The build number the Containerfile bakes into os-release;
51 # see the header of build/build-stamp.sh.
52 # shellcheck source=build/build-stamp.sh
53 . "$REPO_ROOT/build/build-stamp.sh"
54
55 # host_recipe_args. The per-machine dials, so a build host is asked for by name
56 # rather than by remembering four flags; see the header of build/host-recipe.sh.
57 # shellcheck source=build/host-recipe.sh
58 . "$REPO_ROOT/build/host-recipe.sh"
59
60 # preflight_gate. Runs build/preflight.sh ahead of the mint and decides what a
61 # finding means; see the header of build/preflight-gate.sh for which findings
62 # refuse a build and which only warn.
63 # shellcheck source=build/preflight-gate.sh
64 . "$REPO_ROOT/build/preflight-gate.sh"
65
66 IMAGE="localhost/alloy:local"
67 BUILDER="localhost/alloy-iso-builder:local"
68 OUTPUT="$REPO_ROOT/output"
69 WORKDIR="$REPO_ROOT/output/.iso-work"
70 # The install source, kept outside output/ so the rotation does not take it.
71 #
72 # It used to live at $WORKDIR/source, which output/ rotates away on every run,
73 # so every build re-exported the whole image. Measured on this box: 22s of the
74 # ISO's four minutes, paid again for an image that had not changed since the
75 # last run. Out here it survives, and the stamp beside it says which image it
76 # holds.
77 SOURCE_CACHE="$REPO_ROOT/.iso-cache/source"
78 SOURCE_STAMP="$REPO_ROOT/.iso-cache/source.image-id"
79
80 SKIP_BUILD=0
81 SKIP_SOURCE=0
82 FAST=0
83 WRITE_ONLY=0
84 WRITE_DEV=""
85 # Empty means the installer keeps its compiled-in default, the public registry.
86 # See --update-target below.
87 UPDATE_TARGET=""
88 # Empty means make-iso.sh takes the builder's own `uname -m`, which is what
89 # every real build wants: the builder carries the Alloy image's kernel, so
90 # the medium and its contents are the same architecture by construction.
91 # See --arch below.
92 ARCH=""
93 # Empty means no per-machine recipe: the Containerfile's own defaults, which
94 # carry no compiler and no database. See build/host-recipe.sh.
95 HOST=""
96 # Empty means the recipe decides, and a recipe that says nothing means no. See
97 # --nvidia below and the NVIDIA key in build/host-recipe.sh.
98 NVIDIA=""
99 # The preflight runs ahead of every mint that has a recipe. See --no-preflight.
100 NO_PREFLIGHT=0
101
102 BUILD_ARGS=()
103
104 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
105 say() { printf '==> %s\n' "$*"; }
106
107 while [ $# -gt 0 ]; do
108 case "$1" in
109 --skip-build) SKIP_BUILD=1; shift ;;
110 # Passed straight to `podman build`. The Containerfile validates every
111 # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so
112 # the gate lives there rather than here: a bad value has to fail the
113 # build whether it came from `alloy image` or from a hand-typed flag.
114 --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;;
115 # The machine this medium is for, read from build/hosts/<name>.env. Its
116 # dials go in ahead of anything typed here, and an ARCH line in the recipe
117 # sets the medium's architecture unless --arch says otherwise.
118 --host) HOST="${2:?--host needs a machine name}"; shift 2 ;;
119 --skip-source) SKIP_SOURCE=1; shift ;;
120 # Mint without running build/preflight.sh first. The case it exists for is
121 # deliberately building a known-incomplete image -- bisecting a Containerfile
122 # change, or reproducing a defect the preflight would refuse. It is not a way
123 # past a finding you would rather not read.
124 --no-preflight) NO_PREFLIGHT=1; shift ;;
125 # Iteration mode: reuse the image and compress cheaply, but still carry
126 # the install source. Measured on fw13, 2026-08-15: level 19 costs 192s
127 # against level 3's 8s, and the export costs 22s and is now skipped
128 # entirely when the image has not changed. So this flag is worth about
129 # three minutes, and dropping the source would cost the ability to
130 # install at all. Pair with --skip-source when only the boot chain is in
131 # question.
132 --fast) SKIP_BUILD=1; FAST=1; shift ;;
133 # Bakes `alloy.update-target=` into the ISO's GRUB entries, so machines
134 # installed from this medium fetch updates from the named registry instead
135 # of the compiled-in public one. The reason it exists: the public registry
136 # does not exist yet, so without it every install needs a `bootc switch`
137 # before it can take an update at all.
138 --update-target)
139 [ $# -ge 2 ] || die "--update-target needs a registry reference"
140 UPDATE_TARGET="$2"; shift 2 ;;
141 # Overrides the architecture make-iso.sh would detect. For exercising the
142 # arm64-efi GRUB path from an x86_64 box: the module packages are noarch,
143 # so the EFI binary builds anywhere, while the kernel and the squashfs
144 # around it stay whatever the image is. That makes this a test of the boot
145 # chain and not a cross-build, and the ISO it produces boots nothing.
146 --arch)
147 [ $# -ge 2 ] || die "--arch needs x86_64 or aarch64"
148 ARCH="$2"; shift 2 ;;
149 # Build the derived alloy-nvidia image after Alloy itself and put THAT on
150 # the medium -- as the live system and as the install source, since they are
151 # one image here and the derived one is a superset. astra's recipe asks for
152 # it; the flags are how a one-off says otherwise in either direction.
153 #
154 # The card is astra's and the module is compiled against one kernel, so this
155 # only makes sense on the machine whose medium it is. build/build-nvidia.sh
156 # refuses a non-aarch64 host, which is where that is enforced.
157 --nvidia) NVIDIA=yes; shift ;;
158 --no-nvidia) NVIDIA=no; shift ;;
159 # The device write. Same two flags build-image.sh takes, because the
160 # console emits one spelling for both artifacts.
161 --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
162 --write-only) WRITE_ONLY=1; shift ;;
163 # The header block is the help text, so it stops where the comments stop
164 # rather than at a line number that rots the next time the header grows.
165 -h|--help) awk 'NR==1 {next} !/^#/ {exit} {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}"; exit 0 ;;
166 *) die "unknown argument: $1 (see --help)" ;;
167 esac
168 done
169
170 if [ -n "$HOST" ]; then
171 host_recipe_args "$HOST" BUILD_ARGS
172 # --arch wins over the recipe: the flag is how a one-off test of the boot
173 # chain asks for the other architecture, and the recipe is what the machine
174 # normally takes. Same rule for --nvidia.
175 [ -n "$ARCH" ] || ARCH="$HOST_ARCH"
176 [ -n "$NVIDIA" ] || NVIDIA="$HOST_NVIDIA"
177 [ -n "$UPDATE_TARGET" ] || UPDATE_TARGET="$HOST_UPDATE_TARGET"
178 fi
179
180 ARTIFACT="$OUTPUT/install.iso"
181
182 # --write-only skips every build step, including the output rotation, and
183 # writes the ISO already sitting in $OUTPUT. It needs no podman, so it comes
184 # before that check: writing a stick should not depend on being able to build.
185 if [ "$WRITE_ONLY" -eq 1 ]; then
186 [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write"
187 privc test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first"
188 say "writing the ISO already in $OUTPUT"
189 exec "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
190 fi
191
192 command -v podman >/dev/null || die "podman not found"
193
194 # 1. The Alloy image.
195 #
196 # The preflight goes here rather than at the top: --write-only and --skip-build
197 # both reach this point without minting anything, and a gate that refused to
198 # write a stick because a recipe had a finding would be gating the wrong act.
199 if [ "$SKIP_BUILD" -eq 0 ]; then
200 preflight_gate "$HOST" "$ARCH" "$NO_PREFLIGHT"
201 say "building $IMAGE"
202 # --jobs 2 because the Containerfile is two stages that meet only at a COPY.
203 # The rust-build stage compiles the console and the terminal (about four
204 # minutes cold) while the runtime stage is still installing packages (about
205 # the same), and podman runs stages serially unless told otherwise, so the
206 # two costs used to add. Not higher than 2: there are two stages, and a
207 # number above that buys nothing while making the interleaved log harder to
208 # read. The rust stage caps its own rustc jobs from RAM, which is what keeps
209 # the overlap from turning into paging — see the Containerfile.
210 stamp_build_args BUILD_ARGS
211 priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
212 else
213 privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
214 say "reusing $IMAGE"
215 fi
216
217 # 1b. The NVIDIA module, as a thin image over the one just built.
218 #
219 # Everything below reads $IMAGE -- the builder's base, the install source, the
220 # squashfs -- so pointing it at the derived tag here is the whole change. The
221 # derived image is Alloy plus RPM Fusion's kmod and userspace, `FROM` the exact
222 # digest of the image above, so the live system and the installed one carry the
223 # same kernel as the module by construction.
224 #
225 # After the build and not before: build/build-nvidia.sh resolves the digest of
226 # what it derives from, so deriving from a stale localhost/alloy:local would
227 # produce a medium whose module was built for a different kernel than the one
228 # it ships. Deriving here means the two cannot come apart.
229 if [ "$NVIDIA" = yes ]; then
230 say "deriving alloy-nvidia from $IMAGE"
231 "$REPO_ROOT/build/build-nvidia.sh" --base "$IMAGE"
232 IMAGE="localhost/alloy-nvidia:aarch64"
233 privc podman image exists "$IMAGE" \
234 || die "build/build-nvidia.sh reported success and left no $IMAGE"
235 fi
236
237 # 2. The builder, derived from it so the initramfs matches the shipped kernel.
238 say "building $BUILDER"
239 priv podman build -t "$BUILDER" \
240 --build-arg "BASE=$IMAGE" \
241 -f "$REPO_ROOT/build/Containerfile.iso" \
242 "$REPO_ROOT"
243
244 # 3. Somewhere to work. Rotated rather than deleted, same as build-image.sh:
245 # a failed run should not take the last good ISO with it.
246 if [ -d "$OUTPUT" ] && [ -n "$(privc ls -A "$OUTPUT" 2>/dev/null)" ]; then
247 say "rotating previous output to ${OUTPUT}.prev"
248 privc rm -rf "${OUTPUT:?}.prev"
249 privc mv "$OUTPUT" "${OUTPUT}.prev"
250 fi
251 privc mkdir -p "$OUTPUT" "$WORKDIR"
252
253 # 4. The image to install, as an OCI layout. skopeo rather than `podman
254 # save` because bootc reads skopeo transports, and this is the exact
255 # format --source-imgref will be handed.
256 #
257 # A layout (`oci:`) and not an archive (`oci-archive:`). An archive is a
258 # tarball, and the containers stack cannot read a tarball in place: it
259 # untars the whole image into /var/tmp before reading the manifest, and
260 # on the live medium /var/tmp is RAM. Against this image that fails with
261 # `untarring file "/var/tmp/container_images_oci...": write ...` *after*
262 # the installer has wiped and partitioned the target disk, which is the
263 # worst possible moment. Reproduced in a VM at 4 GB and again at 8 GB, so
264 # it is not about the machine being small. A layout is a directory and is
265 # read where it lies. Same bytes on the ISO, no temp space.
266 #
267 # Re-exported only when the image changed. The stamp holds the image ID the
268 # cache was built from, so an unchanged image reuses it and a changed one
269 # starts from an empty directory. EMPTY, not overwritten: skopeo does not
270 # reuse blobs already at an oci: destination — measured 2026-08-15, a second
271 # copy of the same image into the same layout re-copied everything and took
272 # longer than the first — so copying over the top would leave the previous
273 # image's blobs behind and carry both onto the ISO.
274 if [ "$SKIP_SOURCE" -eq 0 ]; then
275 IMAGE_ID="$(privc podman image inspect --format '{{.Id}}' "$IMAGE")"
276 [ -n "$IMAGE_ID" ] || die "cannot read the image id of $IMAGE"
277
278 if [ "$(privc cat "$SOURCE_STAMP" 2>/dev/null || true)" = "$IMAGE_ID" ] \
279 && privc test -f "$SOURCE_CACHE/alloy/index.json"; then
280 say "install source is already exported for this image"
281 else
282 say "exporting the install source (several GB, and slow)"
283 privc rm -rf "${SOURCE_CACHE:?}" "$SOURCE_STAMP"
284 privc mkdir -p "$SOURCE_CACHE"
285 # skopeo from the builder rather than the host: this box is Pop!_OS and
286 # has no skopeo, and requiring one would make the build depend on which
287 # distro happens to be running it. The host's container store is bind
288 # mounted in so skopeo can read the image out of it.
289 priv podman run --rm --privileged \
290 --security-opt label=type:unconfined_t \
291 -v /var/lib/containers/storage:/var/lib/containers/storage \
292 -v "$SOURCE_CACHE":/source \
293 --entrypoint skopeo \
294 "$BUILDER" \
295 copy "containers-storage:$IMAGE" "oci:/source/alloy:local"
296 # Written after the copy, so a run killed half way through leaves a stamp
297 # that does not match and the next build re-exports rather than shipping
298 # a truncated layout.
299 printf '%s\n' "$IMAGE_ID" | privc tee "$SOURCE_STAMP" >/dev/null
300 fi
301 SOURCE_MOUNT="$SOURCE_CACHE"
302 else
303 say "skipping the install source"
304 # An empty directory rather than the cache: --skip-source means the ISO
305 # carries no install source, and mounting a populated cache would put one
306 # on the medium the flag says to leave off.
307 privc mkdir -p "$WORKDIR/source"
308 SOURCE_MOUNT="$WORKDIR/source"
309 fi
310
311 # 5. Mount the image's root filesystem and assemble.
312 #
313 # `podman image mount` gives a path to the composed rootfs without
314 # extracting it, which saves writing several GB to disk only to read it
315 # straight back. Rootful, so it lands in the same store the image is in.
316 say "mounting $IMAGE"
317 ROOTFS="$(privc podman image mount "$IMAGE")"
318 [ -n "$ROOTFS" ] || die "could not mount $IMAGE"
319 trap 'privc podman image umount "$IMAGE" >/dev/null 2>&1 || true' EXIT
320 say "rootfs at $ROOTFS"
321
322 say "assembling the ISO"
323 priv podman run --rm --privileged \
324 -e "ALLOY_ISO_FAST=$FAST" \
325 -e "ALLOY_UPDATE_TARGET=$UPDATE_TARGET" \
326 -e "ALLOY_ISO_ARCH=$ARCH" \
327 --security-opt label=type:unconfined_t \
328 -v "$ROOTFS":/rootfs:ro \
329 -v "$OUTPUT":/output \
330 -v "$SOURCE_MOUNT":/source:ro \
331 "$BUILDER"
332
333 privc test -f "$ARTIFACT" || die "no ISO produced"
334 say "built: $ARTIFACT ($(privc du -h "$ARTIFACT" | cut -f1))"
335 echo
336
337 if [ -n "$WRITE_DEV" ]; then
338 "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
339 else
340 # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso,
341 # which this ISO is the replacement for and does not produce. Pointing at it
342 # sent a write at a path that is not there.
343 echo " Boot it, or write it with:"
344 echo " build/build-iso.sh --write-only --write /dev/sdX"
345 fi
346