Thomas Stallinger 5b9d3fdf6a fix(installer): Zielplatte vor dem Partitionieren freigeben + Kernel-Neueinlesen mit Wiederholung
Live auf echter Hardware gefunden (01.09.2026): "Error: Partition(s) 1
on /dev/sda have been written, but we have been unable to inform the
Kernel of the change, probably because it/they are in use." - parted
schreibt die neue GPT-Tabelle zwar trotzdem, meldet aber einen Fehler,
wenn eine alte Partition (Swap, 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.

Zwei neue Bausteine in image_deploy_partition():
- _image_deploy_release_disk(): unmountet/deaktiviert Swap auf allen
  vorhandenen Partitionen der Zielplatte, bevor ueberhaupt mklabel
  aufgerufen wird.
- _image_deploy_settle_partitions(): wiederholt partprobe/udevadm settle
  bis zu 5x statt nach einem einzigen Versuch aufzugeben, sowohl nach
  mklabel als auch nach dem Anlegen der eigentlichen Partitionen.
  Zusaetzlich wird nach mklabel explizit verifiziert, dass wirklich ein
  gueltiges GPT-Label vorliegt (parted print), statt parteds Exit-Code
  fuer den Kernel-Info-Schritt blind als Gesamtfehlschlag zu werten.

Lokal verifiziert: einmal komplett durchinstalliert (frische Platte),
dann OHNE die Platte zurueckzusetzen ein zweites Mal auf dieselbe,
bereits mit einem echten System belegte Platte installiert - lief
sauber durch, keine Kernel-Fehlermeldung mehr.
2026-09-01 12:00:55 +02:00

396 lines
17 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).
# 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
# "|| 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"
}