#!/usr/bin/env bash
#
# build-iso.sh — build an Alloy installer ISO that boots into `alloy install`.
#
# This is the replacement for the bootc-image-builder path in
# build/build-image.sh, which can only produce Anaconda ISOs: every ISO type
# bib offers ends in Anaconda, and the installer binary lives in the bootc
# image rather than in the live root bib composes from RPMs. See GO task
# a1d037f8. build/build-image.sh keeps its disk-image types (raw, qcow2);
# this script owns the ISO.
#
# Shape of the result:
#   LiveOS/squashfs.img   the Alloy image, as the live system
#   source/alloy/         the same image again, as what gets installed
#   EFI/BOOT + boot/      shim, grub, kernel, live initramfs
#
# The live system runs alloy-installer.service, which is inert unless the
# kernel command line carries `alloy.installer`. Only the GRUB entries here
# set it, so the unit cannot fire on an installed machine.
#
# Usage:
#   build/build-iso.sh                  # build image, then ISO
#   build/build-iso.sh --skip-build     # reuse the current image
#   build/build-iso.sh --skip-source    # omit the OCI layout (faster; the
#                                       # ISO boots but cannot install)
#   build/build-iso.sh --fast           # iteration: cheap compression, keeps source
#   build/build-iso.sh --no-preflight   # mint a knowingly incomplete image
#   build/build-iso.sh --fast --skip-source   # boot chain only, cannot install
#   build/build-iso.sh --update-target host:5000/alloy:43   # updates come from there
#                                       # (or UPDATE_TARGET in the host recipe)
#   build/build-iso.sh --host astra     # mint with astra's recipe, arch included
#   build/build-iso.sh --arch aarch64   # override the detected architecture
#   build/build-iso.sh --nvidia         # install the derived alloy-nvidia image
#   build/build-iso.sh --no-nvidia      # ...even if the recipe asks for it
#   build/build-iso.sh --write /dev/sdX       # build, then write to a device
#   build/build-iso.sh --write-only --write /dev/sdX  # write the ISO already built
#
# Writing is build/write-device.sh's job, shared with build/build-image.sh,
# so there is one implementation of the dd path and one set of guards.

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# priv / privc. run0 where it exists, sudo where it does not; see the header
# of build/privilege.sh for which of the two a call site wants.
# shellcheck source=build/privilege.sh
. "$REPO_ROOT/build/privilege.sh"

# stamp_build_args. The build number the Containerfile bakes into os-release;
# see the header of build/build-stamp.sh.
# shellcheck source=build/build-stamp.sh
. "$REPO_ROOT/build/build-stamp.sh"

# host_recipe_args. The per-machine dials, so a build host is asked for by name
# rather than by remembering four flags; see the header of build/host-recipe.sh.
# shellcheck source=build/host-recipe.sh
. "$REPO_ROOT/build/host-recipe.sh"

# preflight_gate. Runs build/preflight.sh ahead of the mint and decides what a
# finding means; see the header of build/preflight-gate.sh for which findings
# refuse a build and which only warn.
# shellcheck source=build/preflight-gate.sh
. "$REPO_ROOT/build/preflight-gate.sh"

IMAGE="localhost/alloy:local"
BUILDER="localhost/alloy-iso-builder:local"
OUTPUT="$REPO_ROOT/output"
WORKDIR="$REPO_ROOT/output/.iso-work"
# The install source, kept outside output/ so the rotation does not take it.
#
# It used to live at $WORKDIR/source, which output/ rotates away on every run,
# so every build re-exported the whole image. Measured on this box: 22s of the
# ISO's four minutes, paid again for an image that had not changed since the
# last run. Out here it survives, and the stamp beside it says which image it
# holds.
SOURCE_CACHE="$REPO_ROOT/.iso-cache/source"
SOURCE_STAMP="$REPO_ROOT/.iso-cache/source.image-id"

SKIP_BUILD=0
SKIP_SOURCE=0
FAST=0
WRITE_ONLY=0
WRITE_DEV=""
# Empty means the installer keeps its compiled-in default, the public registry.
# See --update-target below.
UPDATE_TARGET=""
# Empty means make-iso.sh takes the builder's own `uname -m`, which is what
# every real build wants: the builder carries the Alloy image's kernel, so
# the medium and its contents are the same architecture by construction.
# See --arch below.
ARCH=""
# Empty means no per-machine recipe: the Containerfile's own defaults, which
# carry no compiler and no database. See build/host-recipe.sh.
HOST=""
# Empty means the recipe decides, and a recipe that says nothing means no. See
# --nvidia below and the NVIDIA key in build/host-recipe.sh.
NVIDIA=""
# The preflight runs ahead of every mint that has a recipe. See --no-preflight.
NO_PREFLIGHT=0

BUILD_ARGS=()

die() { printf 'error: %s\n' "$*" >&2; exit 1; }
say() { printf '==> %s\n' "$*"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --skip-build)  SKIP_BUILD=1; shift ;;
    # Passed straight to `podman build`. The Containerfile validates every
    # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so
    # the gate lives there rather than here: a bad value has to fail the
    # build whether it came from `alloy image` or from a hand-typed flag.
    --build-arg)  BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;;
    # The machine this medium is for, read from build/hosts/<name>.env. Its
    # dials go in ahead of anything typed here, and an ARCH line in the recipe
    # sets the medium's architecture unless --arch says otherwise.
    --host)       HOST="${2:?--host needs a machine name}"; shift 2 ;;
    --skip-source) SKIP_SOURCE=1; shift ;;
    # Mint without running build/preflight.sh first. The case it exists for is
    # deliberately building a known-incomplete image -- bisecting a Containerfile
    # change, or reproducing a defect the preflight would refuse. It is not a way
    # past a finding you would rather not read.
    --no-preflight) NO_PREFLIGHT=1; shift ;;
    # Iteration mode: reuse the image and compress cheaply, but still carry
    # the install source. Measured on fw13, 2026-08-15: level 19 costs 192s
    # against level 3's 8s, and the export costs 22s and is now skipped
    # entirely when the image has not changed. So this flag is worth about
    # three minutes, and dropping the source would cost the ability to
    # install at all. Pair with --skip-source when only the boot chain is in
    # question.
    --fast)        SKIP_BUILD=1; FAST=1; shift ;;
    # Bakes `alloy.update-target=` into the ISO's GRUB entries, so machines
    # installed from this medium fetch updates from the named registry instead
    # of the compiled-in public one. The reason it exists: the public registry
    # does not exist yet, so without it every install needs a `bootc switch`
    # before it can take an update at all.
    --update-target)
      [ $# -ge 2 ] || die "--update-target needs a registry reference"
      UPDATE_TARGET="$2"; shift 2 ;;
    # Overrides the architecture make-iso.sh would detect. For exercising the
    # arm64-efi GRUB path from an x86_64 box: the module packages are noarch,
    # so the EFI binary builds anywhere, while the kernel and the squashfs
    # around it stay whatever the image is. That makes this a test of the boot
    # chain and not a cross-build, and the ISO it produces boots nothing.
    --arch)
      [ $# -ge 2 ] || die "--arch needs x86_64 or aarch64"
      ARCH="$2"; shift 2 ;;
    # Build the derived alloy-nvidia image after Alloy itself and put THAT on
    # the medium -- as the live system and as the install source, since they are
    # one image here and the derived one is a superset. astra's recipe asks for
    # it; the flags are how a one-off says otherwise in either direction.
    #
    # The card is astra's and the module is compiled against one kernel, so this
    # only makes sense on the machine whose medium it is. build/build-nvidia.sh
    # refuses a non-aarch64 host, which is where that is enforced.
    --nvidia)      NVIDIA=yes; shift ;;
    --no-nvidia)   NVIDIA=no; shift ;;
    # The device write. Same two flags build-image.sh takes, because the
    # console emits one spelling for both artifacts.
    --write)       WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
    --write-only)  WRITE_ONLY=1; shift ;;
    # The header block is the help text, so it stops where the comments stop
    # rather than at a line number that rots the next time the header grows.
    -h|--help)     awk 'NR==1 {next} !/^#/ {exit} {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}"; exit 0 ;;
    *)             die "unknown argument: $1 (see --help)" ;;
  esac
done

if [ -n "$HOST" ]; then
  host_recipe_args "$HOST" BUILD_ARGS
  # --arch wins over the recipe: the flag is how a one-off test of the boot
  # chain asks for the other architecture, and the recipe is what the machine
  # normally takes. Same rule for --nvidia.
  [ -n "$ARCH" ] || ARCH="$HOST_ARCH"
  [ -n "$NVIDIA" ] || NVIDIA="$HOST_NVIDIA"
  [ -n "$UPDATE_TARGET" ] || UPDATE_TARGET="$HOST_UPDATE_TARGET"
fi

ARTIFACT="$OUTPUT/install.iso"

# --write-only skips every build step, including the output rotation, and
# writes the ISO already sitting in $OUTPUT. It needs no podman, so it comes
# before that check: writing a stick should not depend on being able to build.
if [ "$WRITE_ONLY" -eq 1 ]; then
  [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write"
  privc test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first"
  say "writing the ISO already in $OUTPUT"
  exec "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
fi

command -v podman >/dev/null || die "podman not found"

# 1. The Alloy image.
#
# The preflight goes here rather than at the top: --write-only and --skip-build
# both reach this point without minting anything, and a gate that refused to
# write a stick because a recipe had a finding would be gating the wrong act.
if [ "$SKIP_BUILD" -eq 0 ]; then
  preflight_gate "$HOST" "$ARCH" "$NO_PREFLIGHT"
  say "building $IMAGE"
  # --jobs 2 because the Containerfile is two stages that meet only at a COPY.
  # The rust-build stage compiles the console and the terminal (about four
  # minutes cold) while the runtime stage is still installing packages (about
  # the same), and podman runs stages serially unless told otherwise, so the
  # two costs used to add. Not higher than 2: there are two stages, and a
  # number above that buys nothing while making the interleaved log harder to
  # read. The rust stage caps its own rustc jobs from RAM, which is what keeps
  # the overlap from turning into paging — see the Containerfile.
  stamp_build_args BUILD_ARGS
  priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
else
  privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
  say "reusing $IMAGE"
fi

# 1b. The NVIDIA module, as a thin image over the one just built.
#
# Everything below reads $IMAGE -- the builder's base, the install source, the
# squashfs -- so pointing it at the derived tag here is the whole change. The
# derived image is Alloy plus RPM Fusion's kmod and userspace, `FROM` the exact
# digest of the image above, so the live system and the installed one carry the
# same kernel as the module by construction.
#
# After the build and not before: build/build-nvidia.sh resolves the digest of
# what it derives from, so deriving from a stale localhost/alloy:local would
# produce a medium whose module was built for a different kernel than the one
# it ships. Deriving here means the two cannot come apart.
if [ "$NVIDIA" = yes ]; then
  say "deriving alloy-nvidia from $IMAGE"
  "$REPO_ROOT/build/build-nvidia.sh" --base "$IMAGE"
  IMAGE="localhost/alloy-nvidia:aarch64"
  privc podman image exists "$IMAGE" \
    || die "build/build-nvidia.sh reported success and left no $IMAGE"
fi

# 2. The builder, derived from it so the initramfs matches the shipped kernel.
say "building $BUILDER"
priv podman build -t "$BUILDER" \
  --build-arg "BASE=$IMAGE" \
  -f "$REPO_ROOT/build/Containerfile.iso" \
  "$REPO_ROOT"

# 3. Somewhere to work. Rotated rather than deleted, same as build-image.sh:
#    a failed run should not take the last good ISO with it.
if [ -d "$OUTPUT" ] && [ -n "$(privc ls -A "$OUTPUT" 2>/dev/null)" ]; then
  say "rotating previous output to ${OUTPUT}.prev"
  privc rm -rf "${OUTPUT:?}.prev"
  privc mv "$OUTPUT" "${OUTPUT}.prev"
fi
privc mkdir -p "$OUTPUT" "$WORKDIR"

# 4. The image to install, as an OCI layout. skopeo rather than `podman
#    save` because bootc reads skopeo transports, and this is the exact
#    format --source-imgref will be handed.
#
#    A layout (`oci:`) and not an archive (`oci-archive:`). An archive is a
#    tarball, and the containers stack cannot read a tarball in place: it
#    untars the whole image into /var/tmp before reading the manifest, and
#    on the live medium /var/tmp is RAM. Against this image that fails with
#    `untarring file "/var/tmp/container_images_oci...": write ...` *after*
#    the installer has wiped and partitioned the target disk, which is the
#    worst possible moment. Reproduced in a VM at 4 GB and again at 8 GB, so
#    it is not about the machine being small. A layout is a directory and is
#    read where it lies. Same bytes on the ISO, no temp space.
#
#    Re-exported only when the image changed. The stamp holds the image ID the
#    cache was built from, so an unchanged image reuses it and a changed one
#    starts from an empty directory. EMPTY, not overwritten: skopeo does not
#    reuse blobs already at an oci: destination — measured 2026-08-15, a second
#    copy of the same image into the same layout re-copied everything and took
#    longer than the first — so copying over the top would leave the previous
#    image's blobs behind and carry both onto the ISO.
if [ "$SKIP_SOURCE" -eq 0 ]; then
  IMAGE_ID="$(privc podman image inspect --format '{{.Id}}' "$IMAGE")"
  [ -n "$IMAGE_ID" ] || die "cannot read the image id of $IMAGE"

  if [ "$(privc cat "$SOURCE_STAMP" 2>/dev/null || true)" = "$IMAGE_ID" ] \
     && privc test -f "$SOURCE_CACHE/alloy/index.json"; then
    say "install source is already exported for this image"
  else
    say "exporting the install source (several GB, and slow)"
    privc rm -rf "${SOURCE_CACHE:?}" "$SOURCE_STAMP"
    privc mkdir -p "$SOURCE_CACHE"
    # skopeo from the builder rather than the host: this box is Pop!_OS and
    # has no skopeo, and requiring one would make the build depend on which
    # distro happens to be running it. The host's container store is bind
    # mounted in so skopeo can read the image out of it.
    priv podman run --rm --privileged \
      --security-opt label=type:unconfined_t \
      -v /var/lib/containers/storage:/var/lib/containers/storage \
      -v "$SOURCE_CACHE":/source \
      --entrypoint skopeo \
      "$BUILDER" \
      copy "containers-storage:$IMAGE" "oci:/source/alloy:local"
    # Written after the copy, so a run killed half way through leaves a stamp
    # that does not match and the next build re-exports rather than shipping
    # a truncated layout.
    printf '%s\n' "$IMAGE_ID" | privc tee "$SOURCE_STAMP" >/dev/null
  fi
  SOURCE_MOUNT="$SOURCE_CACHE"
else
  say "skipping the install source"
  # An empty directory rather than the cache: --skip-source means the ISO
  # carries no install source, and mounting a populated cache would put one
  # on the medium the flag says to leave off.
  privc mkdir -p "$WORKDIR/source"
  SOURCE_MOUNT="$WORKDIR/source"
fi

# 5. Mount the image's root filesystem and assemble.
#
# `podman image mount` gives a path to the composed rootfs without
# extracting it, which saves writing several GB to disk only to read it
# straight back. Rootful, so it lands in the same store the image is in.
say "mounting $IMAGE"
ROOTFS="$(privc podman image mount "$IMAGE")"
[ -n "$ROOTFS" ] || die "could not mount $IMAGE"
trap 'privc podman image umount "$IMAGE" >/dev/null 2>&1 || true' EXIT
say "rootfs at $ROOTFS"

say "assembling the ISO"
priv podman run --rm --privileged \
  -e "ALLOY_ISO_FAST=$FAST" \
  -e "ALLOY_UPDATE_TARGET=$UPDATE_TARGET" \
  -e "ALLOY_ISO_ARCH=$ARCH" \
  --security-opt label=type:unconfined_t \
  -v "$ROOTFS":/rootfs:ro \
  -v "$OUTPUT":/output \
  -v "$SOURCE_MOUNT":/source:ro \
  "$BUILDER"

privc test -f "$ARTIFACT" || die "no ISO produced"
say "built: $ARTIFACT ($(privc du -h "$ARTIFACT" | cut -f1))"
echo

if [ -n "$WRITE_DEV" ]; then
  "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
else
  # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso,
  # which this ISO is the replacement for and does not produce. Pointing at it
  # sent a write at a path that is not there.
  echo "    Boot it, or write it with:"
  echo "      build/build-iso.sh --write-only --write /dev/sdX"
fi
