max / alloy
| 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 | |
| 42 | |
| 43 | REPO_ROOT="" |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 65 | |
| 66 | IMAGE="localhost/alloy:local" |
| 67 | BUILDER="localhost/alloy-iso-builder:local" |
| 68 | OUTPUT="/output" |
| 69 | WORKDIR="/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="/.iso-cache/source" |
| 78 | SOURCE_STAMP="/.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 | |
| 105 | |
| 106 | |
| 107 | while [; do |
| 108 | case "" in |
| 109 | --skip-build) SKIP_BUILD=1; ;; |
| 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 ""); ;; |
| 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=""; ;; |
| 119 | --skip-source) SKIP_SOURCE=1; ;; |
| 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; ;; |
| 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; ;; |
| 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 | [ || |
| 140 | UPDATE_TARGET=""; ;; |
| 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 | [ || |
| 148 | ARCH=""; ;; |
| 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; ;; |
| 158 | --no-nvidia) NVIDIA=no; ;; |
| 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=""; ;; |
| 162 | --write-only) WRITE_ONLY=1; ;; |
| 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) ; ;; |
| 166 | *) ;; |
| 167 | esac |
| 168 | done |
| 169 | |
| 170 | if [; then |
| 171 | |
| 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 | [ || ARCH="" |
| 176 | [ || NVIDIA="" |
| 177 | [ || UPDATE_TARGET="" |
| 178 | fi |
| 179 | |
| 180 | ARTIFACT="/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 [; then |
| 186 | [ || |
| 187 | || |
| 188 | |
| 189 | |
| 190 | fi |
| 191 | |
| 192 | || |
| 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 [; then |
| 200 | |
| 201 | |
| 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 | |
| 211 | |
| 212 | else |
| 213 | || |
| 214 | |
| 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 [; then |
| 230 | |
| 231 | |
| 232 | IMAGE="localhost/alloy-nvidia:aarch64" |
| 233 | |
| 234 | || |
| 235 | fi |
| 236 | |
| 237 | # 2. The builder, derived from it so the initramfs matches the shipped kernel. |
| 238 | |
| 239 | |
| 240 | --build-arg "BASE=" \ |
| 241 | -f "/build/Containerfile.iso" \ |
| 242 | "" |
| 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 [ && [; then |
| 247 | |
| 248 | |
| 249 | |
| 250 | fi |
| 251 | |
| 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 [; then |
| 275 | IMAGE_ID="" |
| 276 | [ || |
| 277 | |
| 278 | if [ \ |
| 279 | && ; then |
| 280 | |
| 281 | else |
| 282 | |
| 283 | |
| 284 | |
| 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 | |
| 290 | --security-opt label=type:unconfined_t \ |
| 291 | -v /var/lib/containers/storage:/var/lib/containers/storage \ |
| 292 | -v "":/source \ |
| 293 | --entrypoint skopeo \ |
| 294 | "" \ |
| 295 | copy "containers-storage:" "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 | | |
| 300 | fi |
| 301 | SOURCE_MOUNT="" |
| 302 | else |
| 303 | |
| 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 | |
| 308 | SOURCE_MOUNT="/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 | |
| 317 | ROOTFS="" |
| 318 | [ || |
| 319 | |
| 320 | |
| 321 | |
| 322 | |
| 323 | |
| 324 | -e "ALLOY_ISO_FAST=" \ |
| 325 | -e "ALLOY_UPDATE_TARGET=" \ |
| 326 | -e "ALLOY_ISO_ARCH=" \ |
| 327 | --security-opt label=type:unconfined_t \ |
| 328 | -v "":/rootfs:ro \ |
| 329 | -v "":/output \ |
| 330 | -v "":/source:ro \ |
| 331 | "" |
| 332 | |
| 333 | || |
| 334 | |
| 335 | |
| 336 | |
| 337 | if [; then |
| 338 | |
| 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 | |
| 344 | |
| 345 | fi |
| 346 |