#!/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). # Vorhandene Partitionen der Zielplatte vor dem Neupartitionieren freigeben # (unmounten, Swap deaktivieren) - live auf echter Hardware gefunden # (01.09.2026): parted schreibt eine neue Partitionstabelle zwar trotzdem # auf die Platte, meldet aber einen Fehler ("...have been unable to inform # the Kernel of the change... probably because it/they are in use"), wenn # eine alte Partition (z.B. Swap oder ein gemountetes Dateisystem vom # vorherigen Betriebssystem auf dieser Platte) noch belegt ist. In QEMU mit # stets frischen Testplatten nie aufgetreten - reale Kundenhardware hat # aber fast immer schon ein Vorleben auf der Platte. _image_deploy_release_disk() { local disk="$1" local part for part in "${disk}"?*; do [[ -b "${part}" ]] || continue swapoff "${part}" 2>/dev/null || true umount --recursive "${part}" 2>/dev/null || true done } # Manche Kernel-/Platten-Kombinationen brauchen mehrere Anlaeufe, bis das # Neueinlesen der Partitionstabelle (BLKRRPART) greift, gerade direkt nach # einem mklabel auf einer zuvor belegten Platte - deshalb mehrfach # probieren statt nach einem einzigen partprobe-Aufruf aufzugeben. _image_deploy_settle_partitions() { local disk="$1" local attempt for attempt in 1 2 3 4 5; do if partprobe "${disk}" 2>/dev/null; then udevadm settle return 0 fi sleep 1 done udevadm settle return 1 } image_deploy_partition() { local disk="$1" local is_efi="$2" _image_deploy_release_disk "${disk}" if ! parted --script "${disk}" mklabel gpt; then image_deploy_log "parted meldete einen Fehler beim Anlegen der Partitionstabelle auf ${disk} (haeufig: eine alte Partition war noch belegt) - pruefe, ob der Kernel die neue Tabelle trotzdem uebernimmt." fi _image_deploy_settle_partitions "${disk}" || { image_deploy_fatal "Kernel konnte die neue Partitionstabelle auf ${disk} auch nach mehreren Versuchen nicht uebernehmen."; return 1; } parted --script "${disk}" print 2>/dev/null | grep -q "Partition Table: gpt" || { image_deploy_fatal "Nach dem Partitionieren liegt kein gueltiges GPT-Label auf ${disk} vor."; 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 _image_deploy_settle_partitions "${disk}" || { image_deploy_fatal "Kernel konnte die neuen Partitionen auf ${disk} nicht uebernehmen."; return 1; } 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 # "--progress-bar" statt "--silent": mit Abstand der laengste Schritt # der ganzen Installation (Minuten statt Sekunden) - Nutzer-Feedback # (01.09.2026, echter Hardware-Test) wollte hier ausdruecklich eine # sichtbare Fortschrittsanzeige statt einer stillen Wartezeit. Der # Balken geht an stderr, unabhaengig vom stdout/stderr-Umleiten des # restlichen Moduls (siehe TUXFLOTTE_VERBOSE_MODULES in utils.sh - # 40_backend.sh bleibt deshalb bewusst von der Kreisel-Kompaktdarstellung # ausgenommen, sonst waere dieser Balken fuer die Person vor dem Geraet # unsichtbar). # # "|| 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 --progress-bar --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. # Uebernimmt das vom Netzwerkmodul exportierte, aktive NetworkManager- # Verbindungsprofil (siehe export_connection_profile() in 05_network.sh) # ins Zielsystem - ohne diesen Schritt bleibt das Profil nur unter # /run/tuxflotte/network/... auf dem Boot-Medium liegen und geht beim # Reboot ins frisch installierte System verloren. Live gefunden # (01.09.2026, echter Hardware-Test): Kunde musste den WLAN-PSK nach dem # ersten Boot erneut eingeben, weil genau dieser Kopierschritt fehlte. # Keine Fatal-Bedingung, falls die Datei fehlt - das Provisionierungs- # interface kann auch unmanaged/kabelgebunden ohne Profil gelaufen sein. image_deploy_install_network_profile() { local target="$1" local profile_file="$2" local dest_dir="${target}/etc/NetworkManager/system-connections" local dest_name [[ -n "${profile_file}" && -f "${profile_file}" ]] || return 0 dest_name="$(basename "${profile_file}")" install -d --mode=0700 --owner=root --group=root "${dest_dir}" || { image_deploy_fatal "Verzeichnis ${dest_dir} konnte nicht angelegt werden"; return 1; } install --mode=0600 --owner=root --group=root \ "${profile_file}" "${dest_dir}/${dest_name}" || { image_deploy_fatal "Verbindungsprofil konnte nicht nach ${dest_dir} kopiert werden"; return 1; } } 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" }