Thomas Stallinger 27d6fa0f47 feat(boot-medium): build_customer_iso.sh ans neue Struktur anpassen (Phase 3) + vier echte Deployment-Bugs behoben
Phase 3: Personalisierung (Aktivierungscode/WLAN) funktioniert jetzt ohne
Xorriso-Extraktion der ganzen ISO / Casper-Hook-Injection. /opt/tuxflotte/
liegt innerhalb von /live/filesystem.squashfs (read-only) - ein direktes
xorriso -update auf eine Datei darin traf live getestet ins Leere.
Stattdessen: Squashfs entpacken (unsquashfs), installer.conf ersetzen, neu
packen (mksquashfs -comp xz, ~1 Minute reine Kompression, kein
debootstrap/apt), als Ganzes per xorriso -update einspielen. Muss als root
laufen (Datei-Eigentuemer im Squashfs sonst falsch).

Beim ersten echten End-to-End-Testlauf mit dem vollen Golden-Image-Archiv
(vorher nie bis zum Ende gekommen - immer am Ubiquity-Bug oder an einem
Commit-Gate gestoppt) vier echte, bisher unentdeckte Bugs gefunden und
behoben:

1. Golden-Image-Download landete in /run/tuxflotte/backend/ (RAM-Tmpfs,
   hier 392 MB) statt auf echtem Speicher - "curl: (23) Failure writing
   output to destination" bei 2,3 GB Archiv, unabhaengig von RAM-Groesse.
   Fix: image_deploy_extract_image_from_url() streamt Download direkt in
   die Extraktion (curl | tar), kein Zwischenspeichern mehr noetig.
   backend_launch() partitioniert/formatiert/mountet jetzt VOR dem
   Download-Versuch statt danach.

2. /etc/resolv.conf im Zielbaum ist bei der echten Referenz-VM ein von
   NetworkManager verwalteter Symlink - "cp" verweigerte das Schreiben
   "through a dangling symlink". Fix: Ziel vor dem Kopieren explizit
   entfernen.

3. mount --bind auf /dev, /proc, /sys schlug fehl, weil diese Verzeichnisse
   nach dem Entpacken gar nicht existierten (package_golden_image.sh
   schliesst sie bewusst aus) - und zwar SILENT, weil das bisherige
   "cmd && stack_ref+=(...)"-Muster einen Fehlschlag unter "set -e" nicht
   als Statement-Fehler wertet. Fiel dadurch erst beim naechsten
   chroot-Aufruf auf ("ssh-keygen -A: Couldn't open /dev/null"), weit weg
   von der eigentlichen Ursache. Fix: neue image_deploy_restore_excluded_dirs()
   legt alle sechs von package_golden_image.sh ausgeschlossenen
   Verzeichnisse (proc/sys/dev/run/tmp/var-tmp) direkt nach der Extraktion
   wieder an; jeder Mount wird jetzt einzeln explizit geprueft statt auf
   &&-Verkettung zu vertrauen.

4. package_golden_image.sh: "--exclude=dev" (ohne "./"-Praefix) matcht bei
   GNU tar gegen JEDEN Verzeichnis-Basisnamen im ganzen Baum, nicht nur
   den Top-Level-Eintrag - hat dadurch auch kernel/drivers/net/can/dev/
   (ein zufaellig gleichnamiger, voellig unverwandter Kernelmodul-Ordner)
   aus dem Archiv gerissen, update-initramfs scheiterte an fehlenden
   can-dev.ko.zst-Abhaengigkeiten. Lokal mit einem Wegwerf-Testbaum
   verifiziert (wie beim "run"-Fund vom selben Tag): "./"-Praefix
   verankert das Muster auf den exakten Top-Level-Pfad, gleichnamige
   verschachtelte Ordner bleiben erhalten. Betrifft das AKTUELL AUF ANODE
   GEHOSTETE Archiv - muss mit dem korrigierten Skript auf der
   Referenz-VM neu gepackt und hochgeladen werden (steht noch aus,
   Nutzer verschlankt die Referenz-VM gerade zusaetzlich).

Live-Fortschritt in der enterprise-QEMU-VM: nach Fix 1-3 kam der
Deployment-Versuch bis kurz vor update-initramfs durch (Partitionierung,
Formatierung, Mounten, Download+Extraktion, chroot-Fixup bis machine-id +
SSH-Hostkeys liefen alle sauber) - Fund 4 (das defekte Archiv) ist der
letzte noch offene Blocker fuer einen vollstaendig erfolgreichen
End-to-End-Durchlauf.
2026-08-31 16:36:35 +02:00

347 lines
15 KiB
Bash

#!/usr/bin/env bash
# Kernmechanik des Golden-Image-Deployments (siehe ADR-0024 und
# platform-docs-Plan "Golden-Image-Deployment statt Ubiquity-
# Automatisierung"). Wird von source eingebunden - nicht eigenstaendig
# ausfuehrbar. Ersetzt den bisherigen Ansatz "nativen Distributions-
# Installer automatisieren" (Ubiquity/Anaconda) durch das curtin/FAI-
# Muster: Zieldatentraeger direkt partitionieren, ein fertiges
# Root-Filesystem-Image entpacken, per chroot nacharbeiten
# (fstab/initramfs/machine-id/SSH-Hostkeys/Bootloader).
#
# Bewusst als eigenstaendige Funktionsbibliothek (wie lib/initrd.sh,
# lib/checks.sh) statt einer einzigen "mach alles"-Funktion - sowohl der
# isolierte Phase-1-Testtreiber als auch das spaetere
# backends/mint-image/backend.sh rufen dieselben Bausteine in derselben
# Reihenfolge auf.
image_deploy_log() {
printf '[image_deploy] %s\n' "$*" >&2
}
image_deploy_fatal() {
printf '[image_deploy] FEHLER: %s\n' "$*" >&2
return 1
}
# Nvme/mmcblk-Geraete brauchen ein "p" vor der Partitionsnummer
# (/dev/nvme0n1p1), sd/vd-Geraete nicht (/dev/sda1) - Heuristik: Geraete-
# name endet auf eine Ziffer -> braucht "p".
_image_deploy_part_suffix() {
local disk="$1"
if [[ "${disk}" =~ [0-9]$ ]]; then
echo "p"
else
echo ""
fi
}
image_deploy_partition_path() {
local disk="$1"
local num="$2"
echo "${disk}$(_image_deploy_part_suffix "${disk}")${num}"
}
# Partitioniert den Zieldatentraeger komplett neu (GPT, wie schon in
# _tuxflotte_render_partman_recipe() begruendet - auch ohne EFI legt GPT
# an, siehe dortiger Kommentar zu bios_grub). Gibt "BOOT_PART ROOT_PART"
# auf stdout aus - BOOT_PART ist bei BIOS leer (die biosgrub-Partition
# braucht kein Dateisystem/keinen Mountpunkt).
image_deploy_partition() {
local disk="$1"
local is_efi="$2"
parted --script "${disk}" mklabel gpt ||
{ image_deploy_fatal "mklabel gpt fehlgeschlagen auf ${disk}"; return 1; }
if [[ "${is_efi}" == "true" ]]; then
parted --script "${disk}" mkpart ESP fat32 1MiB 513MiB &&
parted --script "${disk}" set 1 esp on &&
parted --script "${disk}" mkpart root ext4 513MiB 100% ||
{ image_deploy_fatal "Partitionierung (UEFI) fehlgeschlagen auf ${disk}"; return 1; }
else
parted --script "${disk}" mkpart biosgrub 1MiB 2MiB &&
parted --script "${disk}" set 1 bios_grub on &&
parted --script "${disk}" mkpart root ext4 2MiB 100% ||
{ image_deploy_fatal "Partitionierung (BIOS) fehlgeschlagen auf ${disk}"; return 1; }
fi
partprobe "${disk}" 2>/dev/null || true
udevadm settle
local part1 part2
part1="$(image_deploy_partition_path "${disk}" 1)"
part2="$(image_deploy_partition_path "${disk}" 2)"
[[ -b "${part1}" && -b "${part2}" ]] ||
{ image_deploy_fatal "Partitionen nach partprobe nicht gefunden (${part1}, ${part2})"; return 1; }
if [[ "${is_efi}" == "true" ]]; then
echo "${part1} ${part2}"
else
echo " ${part2}"
fi
}
image_deploy_format() {
local boot_part="$1"
local root_part="$2"
local root_fs="$3"
if [[ -n "${boot_part}" ]]; then
mkfs.vfat -F32 -n ESP "${boot_part}" ||
{ image_deploy_fatal "mkfs.vfat auf ${boot_part} fehlgeschlagen"; return 1; }
fi
case "${root_fs}" in
ext4) mkfs.ext4 -F -L root "${root_part}" ;;
btrfs) mkfs.btrfs -f -L root "${root_part}" ;;
*) image_deploy_fatal "Nicht unterstuetztes Root-Dateisystem: ${root_fs}"; return 1 ;;
esac || { image_deploy_fatal "mkfs auf ${root_part} fehlgeschlagen"; return 1; }
}
image_deploy_mount() {
local target="$1"
local boot_part="$2"
local root_part="$3"
install -d "${target}"
mount "${root_part}" "${target}" ||
{ image_deploy_fatal "Root-Partition ${root_part} konnte nicht auf ${target} gemountet werden"; return 1; }
if [[ -n "${boot_part}" ]]; then
install -d "${target}/boot/efi"
mount "${boot_part}" "${target}/boot/efi" ||
{ image_deploy_fatal "ESP ${boot_part} konnte nicht gemountet werden"; return 1; }
fi
}
# package_golden_image.sh schliesst /proc, /sys, /dev, /run, /tmp, /var/tmp
# bewusst aus dem Archiv aus (Laufzeit-Zustand der Referenz-VM, nicht Teil
# eines portablen Images). Nach dem Entpacken fehlen diese Verzeichnisse
# deshalb komplett - live gefunden (31.08.2026, erster echter Lauf mit dem
# echten Referenz-VM-Archiv): erst "mount --bind /dev" ("mount point does
# not exist"), dann - nach Behebung dessen - "mktemp: failed to create
# directory via template '/var/tmp/mkinitramfs_XXXXXX'" bei
# update-initramfs. Statt jedes Fehlen einzeln zu jagen: alle sechs
# ausgeschlossenen Verzeichnisse direkt nach dem Entpacken einmalig mit
# sinnvollen Rechten wiederherstellen. /dev, /proc, /sys werden gleich
# danach ohnehin ueberbindmounted (image_deploy_bind_mounts) - schadet
# aber nicht, sie schon hier als leere Verzeichnisse anzulegen.
image_deploy_restore_excluded_dirs() {
local target="$1"
mkdir -p "${target}"/{proc,sys,dev,run,tmp,var/tmp}
chmod 1777 "${target}/tmp" "${target}/var/tmp"
}
# Entpackt das per build_golden_image.sh gebaute Golden Image direkt in
# den gemounteten Zielbaum. -p erhaelt Rechte/Eigentuemer, --zstd deckt
# sowohl .tar.zst als auch (falls spaeter gewechselt) andere
# zstd-komprimierte Varianten ab.
image_deploy_extract_image() {
local image_path="$1"
local target="$2"
[[ -r "${image_path}" ]] ||
{ image_deploy_fatal "Golden Image nicht lesbar: ${image_path}"; return 1; }
tar --zstd -xpf "${image_path}" -C "${target}" ||
{ image_deploy_fatal "Golden Image konnte nicht nach ${target} entpackt werden"; return 1; }
image_deploy_restore_excluded_dirs "${target}"
}
# Streamt das Golden Image direkt vom Server in die Extraktion, ohne es
# jemals als Ganzes zwischenzuspeichern - live gefunden (31.08.2026,
# erster echter End-to-End-Lauf mit dem vollen 2,3-GB-Referenz-VM-Archiv
# ueber das neue Boot-Medium): das bisherige "erst nach /run/tuxflotte/...
# herunterladen, dann entpacken" scheiterte an /run (RAM-Tmpfs, oft nur
# ein paar hundert MB gross, unabhaengig von der Geraete-RAM-Groesse) -
# "curl: (23) Failure writing output to destination". Direktes
# Zusammenrohren braucht dagegen nur ein paar MB Puffer, unabhaengig von
# der Archivgroesse. PIPESTATUS wird explizit geprueft statt sich auf
# "pipefail"-Semantik (rechteste fehlgeschlagene Stufe gewinnt) zu
# verlassen, damit die Fehlermeldung eindeutig zwischen Download- und
# Entpack-Fehler unterscheidet.
image_deploy_extract_image_from_url() {
local url="$1"
local target="$2"
local pipe_status
# "|| true" verhindert, dass "set -e" hier sofort abbricht - der
# eigentliche Fehlerfall wird gleich anhand von PIPESTATUS gezielt
# ausgewertet, statt die Pipeline roh durchschlagen zu lassen.
curl --silent --show-error --fail --location "${url}" |
tar --zstd -xpf - -C "${target}" || true
pipe_status=("${PIPESTATUS[@]}")
[[ "${pipe_status[0]}" -eq 0 ]] ||
{ image_deploy_fatal "Golden Image konnte nicht geladen werden: ${url}"; return 1; }
[[ "${pipe_status[1]}" -eq 0 ]] ||
{ image_deploy_fatal "Golden Image konnte nicht nach ${target} entpackt werden"; return 1; }
image_deploy_restore_excluded_dirs "${target}"
}
# /etc/fstab aus den tatsaechlichen Partitions-UUIDs neu erzeugen - kein
# genfstab auf Debian/Mint verfuegbar (das ist ein Arch-Linux-Werkzeug),
# blkid reicht fuer unseren einfachen Fall (Root + optional ESP) locker.
image_deploy_write_fstab() {
local target="$1"
local boot_part="$2"
local root_part="$3"
local root_fs="$4"
local root_uuid
root_uuid="$(blkid -s UUID -o value "${root_part}")"
[[ -n "${root_uuid}" ]] ||
{ image_deploy_fatal "Keine UUID fuer ${root_part} gefunden"; return 1; }
{
echo "# Von image_deploy.sh generiert - siehe ADR-0024."
echo "UUID=${root_uuid} / ${root_fs} defaults 0 1"
if [[ -n "${boot_part}" ]]; then
local boot_uuid
boot_uuid="$(blkid -s UUID -o value "${boot_part}")"
[[ -n "${boot_uuid}" ]] ||
{ image_deploy_fatal "Keine UUID fuer ${boot_part} gefunden"; return 1; }
echo "UUID=${boot_uuid} /boot/efi vfat umask=0077 0 1"
fi
} > "${target}/etc/fstab"
}
# /dev, /proc, /sys, /dev/pts in den Zielbaum einbinden (fuer chroot-
# Operationen, die Geraeteknoten/Kernel-Interfaces brauchen - initramfs,
# grub-install) sowie eine funktionierende DNS-Aufloesung fuers
# apt-get-basierte Bootloader-Nachinstallieren. MOUNTED_STACK wird vom
# aufrufenden Skript vorgehalten (siehe image_deploy_unbind_mounts()).
image_deploy_bind_mounts() {
local target="$1"
local -n stack_ref="$2"
# Golden Image schliesst /dev, /proc, /sys bewusst aus
# (package_golden_image.sh --exclude=dev/sys/proc) - die Zielverzeichnisse
# existieren nach dem Entpacken deshalb noch nicht und muessen vor dem
# Bind-Mount erst angelegt werden. Live gefunden (31.08.2026, erster
# echter Lauf mit der echten Mint-Referenz-VM): ohne das schlaegt
# "mount --bind" mit "mount point does not exist" fehl - und zwar
# SILENT, weil das bisherige "cmd && stack_ref+=(...)"-Muster einen
# Fehlschlag von "cmd" unter "set -e" nicht als Statement-Fehler wertet
# (wohlbekannte Bash-Ausnahme fuer &&/||-Ketten) - der fehlende
# /dev-Bind-Mount fiel dann erst beim naechsten chroot-Aufruf auf
# ("ssh-keygen -A: Couldn't open /dev/null"), weit weg von der
# eigentlichen Ursache. Jetzt: Zielverzeichnisse anlegen, jeden
# Mount-Fehler explizit und einzeln pruefen statt auf &&-Verkettung
# zu vertrauen.
mkdir -p "${target}/dev" "${target}/proc" "${target}/sys" "${target}/dev/pts"
mount --bind /dev "${target}/dev" ||
{ image_deploy_fatal "/dev konnte nicht eingebunden werden"; return 1; }
stack_ref+=("${target}/dev")
mount -t proc proc "${target}/proc" ||
{ image_deploy_fatal "/proc konnte nicht eingebunden werden"; return 1; }
stack_ref+=("${target}/proc")
mount -t sysfs sysfs "${target}/sys" ||
{ image_deploy_fatal "/sys konnte nicht eingebunden werden"; return 1; }
stack_ref+=("${target}/sys")
mount -t devpts devpts "${target}/dev/pts" ||
{ image_deploy_fatal "/dev/pts konnte nicht eingebunden werden"; return 1; }
stack_ref+=("${target}/dev/pts")
# Live gefunden (31.08.2026, erster echter Lauf mit der echten
# Mint-Referenz-VM statt eines debootstrap-Test-Images): dort ist
# /etc/resolv.conf ein von NetworkManager verwalteter Symlink (z.B. auf
# /run/NetworkManager/resolv.conf) - im gerade erst entpackten,
# ungebooteten Zielbaum zeigt der ins Leere, "cp" verweigert dann das
# Schreiben "through a dangling symlink". Ziel deshalb vorher entfernen
# (Symlink oder Datei, beides abgedeckt), erst dann kopieren.
rm -f "${target}/etc/resolv.conf"
cp /etc/resolv.conf "${target}/etc/resolv.conf" ||
{ image_deploy_fatal "resolv.conf konnte nicht nach ${target} kopiert werden"; return 1; }
}
image_deploy_unbind_mounts() {
local -n stack_ref="$1"
local i mnt
for ((i = ${#stack_ref[@]} - 1; i >= 0; i--)); do
mnt="${stack_ref[i]}"
umount --recursive "${mnt}" 2>/dev/null || umount --lazy "${mnt}" 2>/dev/null || true
done
stack_ref=()
}
# machine-id + SSH-Hostkeys werden im Golden Image bewusst NICHT
# mitgeliefert (siehe build_golden_image.sh) - hier, pro tatsaechlich
# ausgerolltem Geraet, frisch erzeugt. update-initramfs regeneriert das
# initrd fuer die tatsaechliche Zielhardware (Golden Image wurde auf
# einem anderen System gebaut).
image_deploy_chroot_fixup() {
local target="$1"
chroot "${target}" systemd-machine-id-setup ||
{ image_deploy_fatal "machine-id konnte nicht erzeugt werden"; return 1; }
chroot "${target}" ssh-keygen -A ||
{ image_deploy_fatal "SSH-Hostkeys konnten nicht erzeugt werden"; return 1; }
chroot "${target}" update-initramfs -u -k all ||
{ image_deploy_fatal "initramfs-Regenerierung fehlgeschlagen"; return 1; }
}
# Grub wird bewusst NICHT ins Golden Image eingebaut (spart Platz, das
# Image kennt beim Bauen das Zielfirmware-Schema noch nicht) - stattdessen
# hier zur Deployment-Zeit nachinstalliert, wo bereits Netzwerk verfuegbar
# ist (Stufe-1-Umgebung hat sich schon fuer die Aktivierung verbunden).
image_deploy_install_bootloader() {
local target="$1"
local disk="$2"
local is_efi="$3"
if [[ "${is_efi}" == "true" ]]; then
chroot "${target}" /bin/bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y grub-efi-amd64
' || { image_deploy_fatal "grub-efi-amd64-Installation fehlgeschlagen"; return 1; }
chroot "${target}" grub-install --target=x86_64-efi \
--efi-directory=/boot/efi --bootloader-id=tuxflotte --recheck ||
{ image_deploy_fatal "grub-install (UEFI) fehlgeschlagen"; return 1; }
# Zusaetzlich auf den Standard-Fallback-Pfad (EFI/BOOT/BOOTX64.EFI)
# installieren - real beim Testen entdeckt: grub-install kann in
# verschachtelten/eingeschraenkten Umgebungen keinen NVRAM-
# Booteintrag setzen ("EFI variables are not supported on this
# system"), ohne --removable bleibt dann NUR der benannte
# /EFI/tuxflotte/-Pfad uebrig, den die Firmware ohne NVRAM-Eintrag
# nie findet ("No bootable option or device was found"). Der
# Fallback-Pfad wird von JEDER UEFI-Firmware ohne NVRAM-Eintrag
# automatisch versucht - robuster fuer heterogene Zielhardware
# generell, nicht nur fuer dieses Testszenario.
chroot "${target}" grub-install --target=x86_64-efi \
--efi-directory=/boot/efi --removable --recheck ||
{ image_deploy_fatal "grub-install (UEFI, removable-Fallback) fehlgeschlagen"; return 1; }
else
chroot "${target}" /bin/bash -c '
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y grub-pc
' || { image_deploy_fatal "grub-pc-Installation fehlgeschlagen"; return 1; }
chroot "${target}" grub-install --target=i386-pc --recheck "${disk}" ||
{ image_deploy_fatal "grub-install (BIOS) fehlgeschlagen"; return 1; }
fi
chroot "${target}" update-grub ||
{ image_deploy_fatal "update-grub fehlgeschlagen"; return 1; }
}
image_deploy_set_hostname() {
local target="$1"
local hostname="$2"
echo "${hostname}" > "${target}/etc/hostname"
printf '127.0.1.1\t%s\n' "${hostname}" >> "${target}/etc/hosts"
}