Add an unattended-install ISO builder
Boot the stick, pick one GRUB entry, walk away: it wipes the target disk, installs Ubuntu 26.04.1 with the NVIDIA driver and codecs, and runs setup.sh on first boot to install the apps. build-iso.sh does the image surgery in a container, so the build machine needs nothing but podman. The wipe is pinned to one disk serial (the 990 PRO). The Data drive can't match it, and an unmatched disk stops the install rather than guessing — so the stick can't eat another machine either. The autoinstall entry is not the GRUB default, so an accidental boot lands in the ordinary Ubuntu installer. Verified by running the install in a VM with two virtual NVMe drives carrying the real serials: unattended start to finish, target partitioned and installed, Data disk byte-for-byte identical, and late-commands left /opt/main-desktop plus the enabled first-boot service in place. Testing caught the autoinstall args landing after '---' (where they reach the installed system instead of the installer, and do nothing), and the first-boot run adding root rather than ckoch to the docker group, since runuser leaves $USER alone. setup.sh gains an unattended mode for that first-boot run, waits out the dpkg lock that Ubuntu's own boot-time upgrades hold, waits for snapd to finish seeding before removing the Firefox snap, and skips the driver step on machines with no NVIDIA card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the unattended-install ISO: the stock Ubuntu desktop ISO, plus this repo, plus an
|
||||
# autoinstall config that wipes ONE disk matched by serial. The image surgery runs in a
|
||||
# container, so nothing needs installing on the machine you build from (Bazzite included).
|
||||
#
|
||||
# scripts/build-iso.sh # downloads the Ubuntu ISO if build/ lacks it
|
||||
# ISO=/path/to/ubuntu-26.04.1-desktop-amd64.iso scripts/build-iso.sh
|
||||
# PASSWORD_HASH='$6$...' scripts/build-iso.sh # skip the password prompt
|
||||
#
|
||||
# Output: build/main-desktop-<date>.iso
|
||||
set -euo pipefail
|
||||
|
||||
UBUNTU_RELEASE=26.04
|
||||
UBUNTU_POINT=26.04.1
|
||||
ISO_NAME="ubuntu-${UBUNTU_POINT}-desktop-amd64.iso"
|
||||
BASE_URL="https://releases.ubuntu.com/${UBUNTU_RELEASE}"
|
||||
BUILDER_IMAGE=docker.io/library/ubuntu:26.04
|
||||
|
||||
# --- the machine this ISO is built for -------------------------------------------------
|
||||
# Serial of the ONLY disk the install may touch: the 1 TB Samsung 990 PRO. The 970 EVO Plus
|
||||
# ("Data", second Steam library, serial S6P7NS0X656465Z) never matches this.
|
||||
TARGET_SERIAL='*S73VNU0Y101555E*'
|
||||
TARGET_MODEL='Samsung SSD 990 PRO 1TB'
|
||||
USERNAME=ckoch
|
||||
REALNAME='Christopher Koch'
|
||||
HOSTNAME=main-desktop
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD="$REPO/build"
|
||||
OUT="${OUT:-$BUILD/main-desktop-$(date +%Y%m%d).iso}"
|
||||
|
||||
step() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; }
|
||||
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
for t in podman curl openssl; do command -v "$t" >/dev/null || die "need $t"; done
|
||||
mkdir -p "$BUILD"
|
||||
|
||||
# --- 1. the login password (hashed into the ISO) ---------------------------------------
|
||||
step "Login password for $USERNAME"
|
||||
if [[ -z ${PASSWORD_HASH:-} ]]; then
|
||||
echo "Hashed into the ISO, so keep the stick to yourself."
|
||||
read -rsp " password: " p1; echo
|
||||
read -rsp " again: " p2; echo
|
||||
[[ -n $p1 && $p1 == "$p2" ]] || die "passwords empty or didn't match"
|
||||
PASSWORD_HASH=$(openssl passwd -6 "$p1")
|
||||
unset p1 p2
|
||||
fi
|
||||
[[ $PASSWORD_HASH == \$6\$* ]] || die "PASSWORD_HASH doesn't look like a SHA-512 crypt hash"
|
||||
|
||||
# --- 2. the Ubuntu ISO ------------------------------------------------------------------
|
||||
step "Ubuntu $UBUNTU_POINT desktop ISO"
|
||||
SRC_ISO="${ISO:-$BUILD/$ISO_NAME}"
|
||||
if [[ ! -f $SRC_ISO ]]; then
|
||||
echo "downloading $ISO_NAME (~6 GB, resumable)"
|
||||
curl -fL -C - --retry 3 -o "$SRC_ISO" "$BASE_URL/$ISO_NAME"
|
||||
fi
|
||||
curl -fsSL -o "$BUILD/SHA256SUMS" "$BASE_URL/SHA256SUMS"
|
||||
want=$(awk -v f="*$(basename "$SRC_ISO")" '$2 == f {print $1}' "$BUILD/SHA256SUMS")
|
||||
if [[ -n $want ]]; then
|
||||
echo "checking sha256…"
|
||||
got=$(sha256sum "$SRC_ISO" | cut -d' ' -f1)
|
||||
[[ $got == "$want" ]] || die "checksum mismatch on $SRC_ISO — delete it and rerun"
|
||||
echo "ok: $got"
|
||||
else
|
||||
echo "warning: $(basename "$SRC_ISO") isn't listed in SHA256SUMS — skipping the check"
|
||||
fi
|
||||
|
||||
# --- 3. what goes on the ISO ------------------------------------------------------------
|
||||
step "Staging the payload"
|
||||
PAYLOAD="$BUILD/payload"
|
||||
rm -rf "$PAYLOAD"
|
||||
mkdir -p "$PAYLOAD/nocloud" "$PAYLOAD/main-desktop/scripts" "$PAYLOAD/main-desktop/iso"
|
||||
sed -e "s|@@PASSWORD_HASH@@|$PASSWORD_HASH|" \
|
||||
-e "s|@@USERNAME@@|$USERNAME|g" \
|
||||
-e "s|@@REALNAME@@|$REALNAME|" \
|
||||
-e "s|@@HOSTNAME@@|$HOSTNAME|" \
|
||||
-e "s|@@TARGET_SERIAL@@|$TARGET_SERIAL|" \
|
||||
-e "s|@@TARGET_MODEL@@|$TARGET_MODEL|" \
|
||||
"$REPO/iso/user-data.in" > "$PAYLOAD/nocloud/user-data"
|
||||
: > "$PAYLOAD/nocloud/meta-data" # cloud-init wants this to exist, empty is fine
|
||||
! grep -qE '@@[A-Z_]+@@' "$PAYLOAD/nocloud/user-data" \
|
||||
|| die "a placeholder went unfilled in user-data: $(grep -oE '@@[A-Z_]+@@' "$PAYLOAD/nocloud/user-data" | sort -u | tr '\n' ' ')"
|
||||
|
||||
# Only the files the installed system needs — never build/ (it holds multi-GB ISOs).
|
||||
cp "$REPO/README.md" "$REPO/flatpaks.txt" "$PAYLOAD/main-desktop/"
|
||||
cp "$REPO/scripts/setup.sh" "$REPO/scripts/firstboot.sh" "$PAYLOAD/main-desktop/scripts/"
|
||||
cp "$REPO/iso/main-desktop-firstboot.service" "$PAYLOAD/main-desktop/iso/"
|
||||
du -sh "$PAYLOAD"
|
||||
|
||||
# --- 4. ISO surgery, in a container -----------------------------------------------------
|
||||
step "Rebuilding the ISO"
|
||||
cat > "$BUILD/_in-container.sh" <<'CONTAINER'
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq >/dev/null
|
||||
apt-get install -y -qq xorriso >/dev/null
|
||||
cd /work
|
||||
SRC=$1 OUT=$2
|
||||
|
||||
# Reuse the original boot entry's kernel/initrd paths rather than hardcoding them.
|
||||
rm -rf grub && mkdir grub
|
||||
xorriso -osirrox on -indev "$SRC" -extract /boot/grub/grub.cfg grub/grub.cfg 2>/dev/null
|
||||
kernel_line=$(grep -m1 -E '^[[:space:]]*linux[[:space:]]' grub/grub.cfg)
|
||||
initrd_line=$(grep -m1 -E '^[[:space:]]*initrd[[:space:]]' grub/grub.cfg)
|
||||
[ -n "$kernel_line" ] && [ -n "$initrd_line" ] || { echo "couldn't read boot entry from grub.cfg"; exit 1; }
|
||||
|
||||
# autoinstall = don't ask for confirmation; ds=nocloud points cloud-init at /nocloud.
|
||||
# The ';' has to be escaped for GRUB's parser.
|
||||
args='autoinstall ds=nocloud\;s=/cdrom/nocloud/'
|
||||
|
||||
# These args are for the INSTALLER, so they must go before the '---' separator — Ubuntu's
|
||||
# stock line is "linux /casper/vmlinuz --- quiet splash", and anything after '---' is
|
||||
# handed to the installed system instead, where autoinstall would do nothing.
|
||||
# Done with string splitting, not sed, so the backslash GRUB needs survives.
|
||||
if [ "${kernel_line#*---}" != "$kernel_line" ]; then
|
||||
new_kernel="${kernel_line%%---*}$args ---${kernel_line#*---}"
|
||||
else
|
||||
new_kernel="$kernel_line $args"
|
||||
fi
|
||||
|
||||
cp grub/grub.cfg grub/grub.cfg.new
|
||||
cat >> grub/grub.cfg.new <<EOF
|
||||
|
||||
menuentry 'Install main-desktop UNATTENDED — ERASES the target disk' {
|
||||
set gfxpayload=keep
|
||||
$new_kernel
|
||||
$initrd_line
|
||||
}
|
||||
EOF
|
||||
chmod +w grub/grub.cfg.new
|
||||
|
||||
# 'replay' reproduces the original's boot equipment (El Torito, MBR, the appended EFI
|
||||
# partition) exactly, instead of trying to rebuild it from scratch.
|
||||
map_args=(-map /work/payload/nocloud /nocloud
|
||||
-map /work/payload/main-desktop /main-desktop
|
||||
-map /work/grub/grub.cfg.new /boot/grub/grub.cfg)
|
||||
if xorriso -osirrox on -indev "$SRC" -lsl /boot/grub/loopback.cfg >/dev/null 2>&1; then
|
||||
cp grub/grub.cfg.new grub/loopback.cfg.new
|
||||
map_args+=(-map /work/grub/loopback.cfg.new /boot/grub/loopback.cfg)
|
||||
fi
|
||||
|
||||
# The volume id is left alone on purpose: casper and GRUB can look the media up by label.
|
||||
xorriso -indev "$SRC" -outdev "$OUT" \
|
||||
-boot_image any replay \
|
||||
-overwrite on \
|
||||
"${map_args[@]}" \
|
||||
-commit
|
||||
CONTAINER
|
||||
|
||||
rm -f "$OUT" # xorriso won't write into an output image that already holds data
|
||||
podman run --rm -v "$BUILD:/work:Z" -w /work "$BUILDER_IMAGE" \
|
||||
bash /work/_in-container.sh "/work/$(basename "$SRC_ISO")" "/work/$(basename "$OUT")"
|
||||
|
||||
step "Done"
|
||||
ls -lh "$OUT"
|
||||
cat <<EOF
|
||||
|
||||
Write it to a USB stick (check the device name first with lsblk!):
|
||||
sudo dd if=$OUT of=/dev/sdX bs=4M status=progress oflag=direct conv=fsync
|
||||
|
||||
Boot it, then pick the last GRUB entry:
|
||||
"Install main-desktop UNATTENDED — ERASES the target disk"
|
||||
It wipes only $TARGET_MODEL (serial $TARGET_SERIAL) and stops if that disk isn't there.
|
||||
EOF
|
||||
Reference in New Issue
Block a user