diff --git a/scripts/build_golden_image.sh b/scripts/build_golden_image.sh new file mode 100755 index 0000000..33f6e2b --- /dev/null +++ b/scripts/build_golden_image.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Baut das schlanke "Golden Image" fuer das Curtin/FAI-artige Deployment- +# Backend (siehe ADR-0024 und den Plan unter +# platform-docs/adr/0024-*.md) - eine reine, unbeaufsichtigte +# debootstrap-Basis OHNE Desktop-Umgebung. Cinnamon/Mint-Tools/Theming +# kommen NICHT hierher, sondern werden nach dem ersten Boot vom +# Provisioning Agent per Ansible-Blueprint ausgerollt (ADR-0002) - dieses +# Image muss nur ueberall booten und Netzwerk haben. +# +# Bewusst reines Debian (nicht Mints Ubuntu-Basis) als Zielsystem: die +# kundensichtbare Identitaet kommt komplett aus der Ansible-Schicht, die +# exakte Basis-Distribution des Images ist zweitrangig - siehe +# Plan-Kontext. Kein Root-Passwort, keine SSH-Hostkeys im Ergebnis (siehe +# Kommentare unten) - echte Zugangsdaten entstehen erst durch den +# Provisioning Agent nach der Installation, ein Golden Image mit +# eingebrannten Zugangsdaten waere ein Sicherheitsrisiko fuer JEDES damit +# ausgerollte Kundengeraet, nicht nur fuer einen einzelnen Testlauf. +# +# Laeuft auf jedem Debian-basierten Host mit debootstrap - keine VM +# noetig (real auf enterprise/LMDE 6 verifiziert, siehe Kommentar bei +# SUITE weiter unten). + +usage() { + echo "Usage: $0 [suite] [arch]" >&2 + echo " suite: Debian-Suite fuer debootstrap (Standard: bookworm)" >&2 + echo " arch: Zielarchitektur (Standard: amd64)" >&2 +} + +OUTPUT_FILE="${1:-}" +# bookworm = Debian 12, dieselbe Basis wie der Build-Host selbst (LMDE 6 +# "faye"), dadurch keine Cross-Suite-Ueberraschungen bei apt/debootstrap. +SUITE="${2:-bookworm}" +ARCH="${3:-amd64}" +MIRROR="http://deb.debian.org/debian" + +if [[ -z "${OUTPUT_FILE}" ]]; then + usage + exit 1 +fi + +if [[ "${EUID}" -ne 0 ]]; then + echo "Fehler: muss als root laufen (debootstrap/chroot/mount)." >&2 + exit 1 +fi + +for cmd in debootstrap chroot tar zstd; do + command -v "${cmd}" >/dev/null 2>&1 || + { echo "Fehler: benötigtes Werkzeug fehlt: ${cmd}" >&2; exit 1; } +done + +WORK_DIR="$(mktemp -d /var/tmp/tuxflotte-golden-image.XXXXXX)" +readonly WORK_DIR + +MOUNTED=() + +cleanup() { + local mnt + # In umgekehrter Reihenfolge aushaengen - /dev/pts vor /dev usw. + for ((i = ${#MOUNTED[@]} - 1; i >= 0; i--)); do + mnt="${MOUNTED[i]}" + umount --recursive "${mnt}" 2>/dev/null || + umount --lazy "${mnt}" 2>/dev/null || true + done + chmod -R u+w "${WORK_DIR}" 2>/dev/null || true + rm -rf "${WORK_DIR}" +} +trap cleanup EXIT + +bind_mount() { + local target="$1" + mount --bind "${target}" "${WORK_DIR}${target}" + MOUNTED+=("${WORK_DIR}${target}") +} + +echo "==> debootstrap ${SUITE}/${ARCH} nach ${WORK_DIR}" +debootstrap --arch="${ARCH}" "${SUITE}" "${WORK_DIR}" "${MIRROR}" + +echo "==> apt-Quellen fuer non-free-firmware + backports einrichten" +# non-free-firmware ist seit Debian 12 eine eigene, standardmaessig NICHT +# aktive Komponente (anders als bei Ubuntu/Mint) - ohne sie fehlt auf +# vielen echten Geraeten die WLAN-/GPU-Firmware. Backports fuer einen +# neueren Kernel (bessere Hardwareunterstuetzung auf aktueller Hardware +# als der Suite-Standardkernel). +cat > "${WORK_DIR}/etc/apt/sources.list" < /dev, /proc, /sys, /dev/pts einbinden fuer chroot-Paketinstallation" +bind_mount /dev +mount -t proc proc "${WORK_DIR}/proc" +MOUNTED+=("${WORK_DIR}/proc") +mount -t sysfs sysfs "${WORK_DIR}/sys" +MOUNTED+=("${WORK_DIR}/sys") +mount -t devpts devpts "${WORK_DIR}/dev/pts" +MOUNTED+=("${WORK_DIR}/dev/pts") + +# DNS-Aufloesung im chroot braucht eine eigene resolv.conf - der +# Netzwerk-Namespace wird zwar geteilt, aber /etc/resolv.conf ist eine +# normale Datei innerhalb des debootstrap-Baums, nicht automatisch mit +# der des Hosts identisch. +cp /etc/resolv.conf "${WORK_DIR}/etc/resolv.conf" + +echo "==> Pakete im chroot installieren (Kernel, Firmware, Netzwerk, SSH)" +# Paketliste ist ein erster, plausibler Startpunkt - wird nach dem ersten +# echten Boot-Test (Phase 1 des Plans) voraussichtlich noch angepasst, +# je nachdem was auf der Testhardware/-VM tatsaechlich fehlt. +chroot "${WORK_DIR}" /bin/bash -c ' + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y \ + linux-image-amd64 \ + firmware-linux \ + firmware-realtek \ + firmware-iwlwifi \ + firmware-atheros \ + firmware-misc-nonfree \ + network-manager \ + openssh-server \ + sudo \ + locales \ + curl \ + ca-certificates \ + parted + apt-get clean +' + +echo "==> Golden Image von eingebrannten Zugangsdaten befreien" +# Debootstrap setzt kein Root-Passwort (Konto bleibt gesperrt) - das +# bleibt bewusst so. openssh-server generiert beim Postinst aber eigene +# Hostkeys, die JEDES aus diesem Image ausgerollte Geraet sonst identisch +# mit-ausliefern wuerde (Kollisionsrisiko + Sicherheitsproblem) - werden +# hier entfernt, die Deployment-Mechanik (Plan Phase 1) erzeugt sie beim +# eigentlichen Ausrollen pro Geraet frisch. +rm -f "${WORK_DIR}"/etc/ssh/ssh_host_* +: > "${WORK_DIR}/etc/machine-id" + +echo "==> Aushaengen" +for ((i = ${#MOUNTED[@]} - 1; i >= 0; i--)); do + umount --recursive "${MOUNTED[i]}" +done +MOUNTED=() + +echo "==> Packe als ${OUTPUT_FILE}" +tar --numeric-owner --xattrs -C "${WORK_DIR}" -cf - . | + zstd -T0 -19 -o "${OUTPUT_FILE}" + +echo "==> Fertig: ${OUTPUT_FILE} ($(du -h "${OUTPUT_FILE}" | cut -f1))" diff --git a/scripts/image_deploy_test.sh b/scripts/image_deploy_test.sh new file mode 100755 index 0000000..dc48043 --- /dev/null +++ b/scripts/image_deploy_test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Isolierter Testtreiber fuer scripts/lib/image_deploy.sh (Phase 1 des +# Golden-Image-Deployment-Plans, siehe ADR-0024) - direkt in einer +# frischen Live-Mint-Session ausgefuehrt, OHNE Umweg ueber Kiosk/ +# Aktivierung/Kexec, um den Iterationszyklus kurz zu halten. Wird erst in +# Phase 2 durch backends/mint-image/backend.sh ersetzt/ergaenzt, das +# dieselben Bibliotheksfunktionen im echten Self-Service-Ablauf aufruft. +# +# WARNUNG: ueberschreibt den angegebenen Datentraeger vollstaendig. Nur +# auf einer Test-VM mit einer garantiert leeren/wegwerfbaren Platte +# ausfuehren. + +usage() { + echo "Usage: sudo $0 [root_fs] [hostname]" >&2 + echo " disk: z.B. /dev/vda oder /dev/sda - WIRD KOMPLETT ÜBERSCHRIEBEN" >&2 +} + +DISK="${1:-}" +IMAGE_PATH="${2:-}" +ROOT_FS="${3:-ext4}" +HOSTNAME_VALUE="${4:-tuxflotte-image-test}" + +if [[ -z "${DISK}" || -z "${IMAGE_PATH}" ]]; then + usage + exit 1 +fi + +if [[ "${EUID}" -ne 0 ]]; then + echo "Fehler: muss als root laufen." >&2 + exit 1 +fi + +[[ -b "${DISK}" ]] || { echo "Fehler: ${DISK} ist kein Blockgerät." >&2; exit 1; } + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/image_deploy.sh +source "${REPO_DIR}/scripts/lib/image_deploy.sh" + +TARGET="/target" +declare -a MOUNT_STACK=() + +cleanup() { + image_deploy_unbind_mounts MOUNT_STACK + umount -R "${TARGET}" 2>/dev/null || true +} +trap cleanup EXIT + +if [[ -d /sys/firmware/efi ]]; then + IS_EFI="true" +else + IS_EFI="false" +fi +image_deploy_log "Firmware-Modus: $([ "${IS_EFI}" = true ] && echo UEFI || echo BIOS)" + +image_deploy_log "Schritt 1/8: Partitionieren von ${DISK}" +read -r BOOT_PART ROOT_PART <<<"$(image_deploy_partition "${DISK}" "${IS_EFI}")" +image_deploy_log " -> BOOT_PART='${BOOT_PART}' ROOT_PART='${ROOT_PART}'" + +image_deploy_log "Schritt 2/8: Formatieren" +image_deploy_format "${BOOT_PART}" "${ROOT_PART}" "${ROOT_FS}" + +image_deploy_log "Schritt 3/8: Mounten unter ${TARGET}" +image_deploy_mount "${TARGET}" "${BOOT_PART}" "${ROOT_PART}" + +image_deploy_log "Schritt 4/8: Golden Image entpacken (${IMAGE_PATH})" +image_deploy_extract_image "${IMAGE_PATH}" "${TARGET}" + +image_deploy_log "Schritt 5/8: fstab schreiben" +image_deploy_write_fstab "${TARGET}" "${BOOT_PART}" "${ROOT_PART}" "${ROOT_FS}" + +image_deploy_log "Schritt 6/8: bind mounts + chroot-Fixup (machine-id/SSH-Hostkeys/initramfs)" +image_deploy_bind_mounts "${TARGET}" MOUNT_STACK +image_deploy_chroot_fixup "${TARGET}" + +image_deploy_log "Schritt 7/8: Bootloader installieren" +image_deploy_install_bootloader "${TARGET}" "${DISK}" "${IS_EFI}" + +image_deploy_log "Schritt 8/8: Hostname setzen (${HOSTNAME_VALUE})" +image_deploy_set_hostname "${TARGET}" "${HOSTNAME_VALUE}" + +image_deploy_log "Fertig. Zum Test: VM neu starten und von ${DISK} booten." diff --git a/scripts/lib/image_deploy.sh b/scripts/lib/image_deploy.sh new file mode 100644 index 0000000..569ebaa --- /dev/null +++ b/scripts/lib/image_deploy.sh @@ -0,0 +1,243 @@ +#!/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 +} + +# 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; } +} + +# /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" + + mount --bind /dev "${target}/dev" && stack_ref+=("${target}/dev") + mount -t proc proc "${target}/proc" && stack_ref+=("${target}/proc") + mount -t sysfs sysfs "${target}/sys" && stack_ref+=("${target}/sys") + mount -t devpts devpts "${target}/dev/pts" && stack_ref+=("${target}/dev/pts") + cp /etc/resolv.conf "${target}/etc/resolv.conf" +} + +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; } + 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" +}