# shellcheck shell=sh # # Die goldene Vorlage: ein Debian-13-Cloud-Abbild, in das Docker, Compose und # die Nextcloud-Aufstellung eingebaut werden, konvertiert zur Proxmox-Vorlage # auf VMID 9000. # # --------------------------------------------------------------------------- # Die drei Fallen aus Block A # --------------------------------------------------------------------------- # # Sie sind nicht verhandelbar, und sie werden hier alle drei GEPRÜFT statt # angenommen — jede von ihnen hat einmal einen bezahlten Auftrag gekostet: # # 1. `user: www-data` am `app`-Dienst der Compose-Datei. Ohne sie schlagen ALLE # `occ`-Aufrufe fehl. Steht in `assets/docker-compose.yml`; hier wird # nachgesehen, dass sie es auch ins Abbild geschafft hat. # 2. Die Root-Partition ist die LETZTE auf der Platte und liegt NICHT auf LVM. # `GrowGuestFilesystem` erkennt LVM absichtlich nicht und schriebe sonst ein # Kontingent über ein Dateisystem, das nie gewachsen ist. # 3. `qemu-guest-agent` MUSS im Abbild sein — `WaitForGuestAgent` hängt daran, # und ohne ihn wartet jede Bereitstellung bis zum Zeitablauf. # # --------------------------------------------------------------------------- # Warum das Cloud-Abbild und nicht der Installer # --------------------------------------------------------------------------- # # Debians Cloud-Abbild bringt genau die Eigenschaften mit, die Falle 2 verlangt: # eine einzige Root-Partition, als letzte auf der Platte, ohne LVM. Und es hat # cloud-init, worüber die Pipeline beim Klonen die `.env` in den Gast schreibt. # Beides selbst zu bauen hieße, es beim ersten Kunden zu prüfen. CLUPILOT_TEMPLATE_VMID="${CLUPILOT_TEMPLATE_VMID:-9000}" CLUPILOT_TEMPLATE_NAME='clupilot-nextcloud-vorlage' CLUPILOT_CLOUD_IMAGE_BASE="${CLUPILOT_CLOUD_IMAGE_BASE:-https://cloud.debian.org/images/cloud/trixie/latest}" CLUPILOT_CLOUD_IMAGE='debian-13-genericcloud-amd64.qcow2' # Wieviel unter dem Arbeitsverzeichnis frei sein muss, in MB. Abbild, # Arbeitskopie und die importierte Platte liegen gleichzeitig da, dazu die # Appliance von virt-customize — ein Vielfaches der Abbildgröße. CLUPILOT_TEMPLATE_MIN_FREE_MB="${CLUPILOT_TEMPLATE_MIN_FREE_MB:-12000}" # Wohin die Vorlagenplatte kommt. `local-zfs` ist das, was der PVE-Installer auf # ZFS anlegt; `local-lvm` wäre es bei ext4. Zur Laufzeit gesucht statt geraten. # # Gefragt wird EINMAL nach der Menge der Ablagen, die Platten aufnehmen, und die # Wunschkandidaten werden darin gesucht. Vorher fragte die Schleife # `pvesm status --storage X --content images` nach dem Rückgabewert ab — der # sagt aber nicht zuverlässig „diese Ablage nimmt Platten", und `local` hätte so # durchgehen können, obwohl ein Import dorthin erst nach dem Kopieren scheitert. # # `$3 == "active"`: eine abgemeldete oder tote Netzablage steht weiterhin in der # Liste und wäre die schlechteste denkbare Wahl. detect_vm_storage() { _capable="$(pvesm status --content images 2>/dev/null | awk 'NR > 1 && $3 == "active" { print $1 }')" if [ -z "$_capable" ]; then return 0 fi for _candidate in local-zfs local-lvm local; do if printf '%s\n' "$_capable" | grep -qx "$_candidate"; then printf '%s' "$_candidate" return 0 fi done printf '%s' "$(printf '%s\n' "$_capable" | head -1)" } # Eine Zeile je Ablage aus /etc/pve/storage.cfg: " ". # Leere Felder als "-", damit die Spaltennummern stehen bleiben. # # Der Pfad ist überschreibbar, damit die Auswertung dieser Datei ohne einen # Proxmox-Host durchgespielt werden kann — sie ist das Stück neuer Logik mit dem # größten Anteil an awk und dem kleinsten an „sieht man sofort". CLUPILOT_STORAGE_CFG="${CLUPILOT_STORAGE_CFG:-/etc/pve/storage.cfg}" storage_table() { awk ' function flush() { if (name != "") { printf "%s %s %s %s\n", name, type, (content == "" ? "-" : content), (path == "" ? "-" : path) } } /^[a-z]+:[[:space:]]/ { flush() type = substr($1, 1, length($1) - 1); name = $2; content = ""; path = "" next } /^[[:space:]]+content[[:space:]]/ { content = $2; next } /^[[:space:]]+path[[:space:]]/ { path = $2; next } END { flush() } ' "$CLUPILOT_STORAGE_CFG" 2>/dev/null } # Sorgt dafür, dass überhaupt eine Ablage Platten annimmt — und greift NUR ein, # wenn keine es tut. # # Ein per Debian aufgesetztes Proxmox hat nur `local`, eine Verzeichnis-Ablage, # deren Inhaltsliste ab Werk keine Platten enthält. `detect_vm_storage` findet # dann nichts und der Bau stürbe vor der ersten Zeile. Schlimmer ist der leise # Teil: `RegisterCapacity` zählt später die Ablagen, die Platten aufnehmen, und # meldete Kapazität 0 — ein Host, der in der Liste fertig aussieht und auf den # nie ein Kunde gelegt werden kann. # # Zwei Dinge passieren hier bewusst NICHT: # # - Eine feste Inhaltsliste schreiben. Was jemand auf diesem Host ergänzt hat, # wäre weg. Die vorhandene wird gelesen und `images` ANGEHÄNGT. # - In /etc/pve/storage.cfg schreiben. Das ist pmxcfs; ein von Hand # geschriebener Eintrag umgeht Prüfung und Neuladen. Gelesen wird die Datei, # geschrieben wird über `pvesm set`. # # Ein Host mit local-lvm oder local-zfs kommt hier nie an: die nehmen Platten # längst, `detect_vm_storage` liefert sie, und an `local` wird nicht gerührt. ensure_image_storage() { if [ -n "$(detect_vm_storage)" ]; then return 0 fi # `local` zuerst, sonst die erste Ablage einer Art, die Platten überhaupt # tragen kann. Netzablagen (nfs/cifs) stehen bewusst nicht in der Liste: # deren Inhaltsliste zu ändern ist eine Entscheidung über fremden Speicher. _name="$(storage_table | awk ' $2 ~ /^(dir|btrfs|zfspool|lvmthin|lvm)$/ { if ($1 == "local") { print $1; found = 1; exit } if (first == "") first = $1 } END { if (!found && first != "") print first } ')" if [ -z "$_name" ]; then log 'Keine Ablage auf diesem Host nimmt Platten auf, und keine, der es beizubringen wäre.' return 1 fi _content="$(storage_table | awk -v n="$_name" '$1 == n { print $3 }')" if [ "$_content" = '-' ] || [ -z "$_content" ]; then _new='images' else _new="${_content},images" fi log "Ablage ${_name} nimmt keine Platten auf; Inhaltsliste wird erweitert: ${_new}" if ! pvesm set "$_name" --content "$_new" >> "${CLUPILOT_WORK_DIR}/pvesm.log" 2>&1; then log "pvesm set ${_name} --content ${_new} fehlgeschlagen" return 1 fi # Nachgesehen, nicht aus dem Rückgabewert geschlossen. if [ -z "$(detect_vm_storage)" ]; then log "Ablage ${_name} nimmt auch nach der Erweiterung keine Platten auf" return 1 fi return 0 } # Freier Platz auf jedem übergebenen Pfad, bevor irgendetwas geladen wird. # # Ein df vorab mit klarer Absage ist billiger als ein Abbruch nach zwanzig # Minuten mitten im virt-customize. Pfade statt einer Ablagengröße, weil `df` # in derselben Einheit antwortet, egal wen man fragt — die Spalten von # `pvesm status` tun das nicht. # # Block-Ablagen (lvmthin, zfspool) haben keinen Pfad und werden nicht geprüft; # dort scheitert der Import laut und sofort statt nach dem Kopieren. check_build_space() { for _path in "$@"; do [ -z "$_path" ] && continue [ "$_path" = '-' ] && continue [ -d "$_path" ] || continue _free="$(df -Pm "$_path" 2>/dev/null | awk 'NR == 2 { print $4 }')" if [ -z "$_free" ]; then log "Freier Platz unter ${_path} nicht feststellbar" return 1 fi if [ "$_free" -lt "$CLUPILOT_TEMPLATE_MIN_FREE_MB" ]; then log "Zu wenig Platz unter ${_path}: ${_free} MB frei, ${CLUPILOT_TEMPLATE_MIN_FREE_MB} MB nötig." return 1 fi log "Platz unter ${_path}: ${_free} MB frei" done return 0 } # Lädt das Cloud-Abbild und prüft es gegen Debians SHA512SUMS. # # Geladen wird nach `.part`, umbenannt erst nach geprüfter Summe. Vorher schrieb # die Funktion direkt ans Ziel, und ein abgebrochener Lauf hinterließ eine # abgeschnittene Datei — die der nächste Lauf wegen `[ ! -f "$_image" ]` # ungeprüft weiterbenutzt hätte. Unter dem Zielnamen liegt damit nur, was die # Prüfsumme bestanden hat. fetch_cloud_image() { _target="$1" _part="${_target}.part" rm -f "$_part" if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_part"; then log "Cloud-Abbild nicht ladbar: ${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" rm -f "$_part" return 1 fi _sums="$(http_get "${CLUPILOT_CLOUD_IMAGE_BASE}/SHA512SUMS" || true)" if [ -z "$_sums" ]; then log 'SHA512SUMS von cloud.debian.org nicht abrufbar' rm -f "$_part" return 1 fi _expected="$(printf '%s\n' "$_sums" | awk -v n="$CLUPILOT_CLOUD_IMAGE" '$2 == n || $2 == "*" n { print $1; exit }')" if [ -z "$_expected" ]; then log "keine Prüfsumme für ${CLUPILOT_CLOUD_IMAGE}" rm -f "$_part" return 1 fi _actual="$(sha512sum "$_part" | awk '{ print $1 }')" if [ "$_actual" != "$_expected" ]; then log 'Prüfsumme des Cloud-Abbilds stimmt nicht' rm -f "$_part" return 1 fi mv "$_part" "$_target" || { log 'Abbild ließ sich nicht an seinen Platz schieben'; return 1; } log 'Cloud-Abbild geladen, Prüfsumme stimmt' return 0 } # Aus der Ausgabe von `guestfish … part-list`: die Nummer der Partition, die am # WEITESTEN HINTEN auf der Platte beginnt. # # Eigene Funktion, damit sie sich ohne Proxmox und ohne libguestfs gegen # aufgezeichnete Ausgaben durchspielen lässt — sie ist der Kern von Falle 2 und # hat schon einmal das Falsche gesagt. last_partition_number() { awk ' /part_num:/ { num = $2 } /part_start:/ { if ($2 + 0 > max + 0) { max = $2 + 0; last = num } } END { print last } ' } # Aus derselben Ausgabe: wo eine bestimmte Partition beginnt (für die Meldung). partition_start() { awk -v want="$1" ' /part_num:/ { num = $2 } /part_start:/ { if (num == want) { print $2; exit } } ' } # FALLE 2, geprüft statt angenommen. # # Zwei Aussagen: kein LVM, und hinter der Root-Partition liegt nichts mehr. # # --------------------------------------------------------------------------- # Warum nach dem ANFANG gefragt wird und nicht nach der Nummer # --------------------------------------------------------------------------- # # Hier stand ein Vergleich der Partitions-NAMEN: die letzte war die, die # `sort -V` nach hinten sortiert. Das ist bei Debians Cloud-Abbild genau falsch # herum und hat es am 1. August 2026 abgelehnt — dem einzigen Abbild, für das # diese Prüfung je gedacht war. # # Debian legt die BIOS-Boot- und die EFI-Partition an den ANFANG der Platte und # nummeriert sie als 14 und 15; die Wurzel ist Nummer 1 und liegt physisch # dahinter. Das ist Absicht und ist genau die Eigenschaft, die Falle 2 verlangt: # nur so kann `growpart` die Wurzel über den freien Rest ausdehnen. Nach Namen # sortiert sah `/dev/sda15` wie die letzte aus, und die Prüfung verweigerte ein # Abbild, das ihre Bedingung erfüllte. # # `guestfish part-list` gibt Anfangs-Offsets in Bytes. Danach wird gefragt, # weil das die Frage IST — „nichts liegt dahinter" ist eine Aussage über die # Platte, nicht über die Benennung. verify_image_layout() { _image="$1" _fs="$(virt-filesystems --long --parts --blkdevs -h -a "$_image" 2>/dev/null || true)" if [ -z "$_fs" ]; then log 'virt-filesystems liefert nichts — Abbildaufbau nicht prüfbar' return 1 fi if printf '%s' "$_fs" | grep -qiE '(lvm|/dev/[a-z0-9_-]+/[a-z0-9_-]+)'; then log 'Das Abbild benutzt LVM. GrowGuestFilesystem erkennt LVM absichtlich nicht und schriebe ein Kontingent über ein Dateisystem, das nie gewachsen ist.' return 1 fi _root_part="$(virt-filesystems --long -a "$_image" 2>/dev/null | awk '$0 !~ /swap/ && /ext4|xfs|btrfs/ { print $1; }' | sort -V | tail -1)" if [ -z "$_root_part" ]; then log 'Keine Wurzel im Abbild gefunden (kein ext4/xfs/btrfs) — Abbildaufbau nicht prüfbar' return 1 fi _root_num="$(printf '%s' "$_root_part" | sed 's/.*[^0-9]\([0-9][0-9]*\)$/\1/')" _parts="$(guestfish --ro -a "$_image" run : part-list /dev/sda 2>/dev/null)" if [ -z "$_parts" ]; then log 'guestfish part-list liefert nichts — die Lage der Partitionen ist nicht prüfbar' return 1 fi _last_num="$(printf '%s\n' "$_parts" | last_partition_number)" if [ -z "$_last_num" ]; then log 'part-list liefert keine Partitionen — Abbildaufbau nicht prüfbar' return 1 fi if [ "$_last_num" != "$_root_num" ]; then # Die Zahlen mit in die Meldung: scheitert das je wieder, soll die # nächste Person die Platte im Protokoll sehen und nicht raten müssen. log "Hinter der Wurzel (Partition ${_root_num}, Beginn $(printf '%s\n' "$_parts" | partition_start "$_root_num") Bytes) beginnt noch Partition ${_last_num} (Beginn $(printf '%s\n' "$_parts" | partition_start "$_last_num") Bytes). GrowGuestFilesystem vergrößert nur, was nichts hinter sich hat." return 1 fi log "Abbildaufbau in Ordnung: kein LVM, Wurzel auf ${_root_part} und nichts dahinter" return 0 } # Baut alles in das Abbild ein, was die Vorlage ausmacht. # # `virt-customize` statt „VM starten und hineinreden": es braucht kein Netz im # Gast, keine Anmeldung und keinen laufenden Rechner, und es ist wiederholbar. customise_cloud_image() { _image="$1" _compose="$2" # `qemu-guest-agent`: ohne ihn hängt jede Bereitstellung an # WaitForGuestAgent bis zum Zeitablauf — Falle 3. # # `cloud-guest-utils` bringt `growpart` — Falle 4. `GrowGuestFilesystem` # ruft es auf, und installiert hat es nie jemand: es lief bisher, weil # Debians Cloud-Abbild es zufällig mitbringt. Fiele es dort heraus, bekäme # jeder Kunde stillschweigend ein Kontingent über ein Dateisystem, das nie # gewachsen ist — bemerkt erst, wenn eine Platte voll ist, die laut Rechnung # dreimal so groß sein sollte. Hier ausdrücklich installiert statt # vorausgesetzt, und unten nachgewiesen statt geglaubt. _packages='qemu-guest-agent,cloud-guest-utils,ca-certificates,curl,gnupg' if ! virt-customize -a "$_image" \ --install "$_packages" \ --run-command 'systemctl enable qemu-guest-agent' \ >> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1 then log 'virt-customize: Grundpakete ließen sich nicht einbauen' return 1 fi # Docker aus der Quelle von Docker selbst, nicht aus Debian: `docker.io` hat # kein Compose-Plugin, und `docker-compose` in Debian ist die alte # Python-Fassung, die diese Compose-Datei nicht liest. if ! virt-customize -a "$_image" \ --run-command 'install -m 0755 -d /etc/apt/keyrings' \ --run-command 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc' \ --run-command 'chmod a+r /etc/apt/keyrings/docker.asc' \ --run-command 'echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" > /etc/apt/sources.list.d/docker.list' \ --run-command 'apt-get update' \ --install 'docker-ce,docker-ce-cli,containerd.io,docker-buildx-plugin,docker-compose-plugin' \ --run-command 'systemctl enable docker' \ >> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1 then log 'virt-customize: Docker ließ sich nicht einbauen' return 1 fi if ! virt-customize -a "$_image" \ --mkdir /opt/nextcloud \ --copy-in "${_compose}:/opt/nextcloud" \ --run-command 'systemctl enable docker.service containerd.service' \ >> "${CLUPILOT_WORK_DIR}/virt-customize.log" 2>&1 then log 'virt-customize: die Compose-Datei kam nicht ins Abbild' return 1 fi return 0 } # Welche Pakete aus einer Liste im Abbild NICHT installiert sind. # # Gefragt wird die dpkg-Datenbank, nicht ein Pfad — und das ist der Unterschied, # der den ersten Bau auf echter Hardware abgebrochen hat. Die Prüfung suchte # `/usr/bin/qemu-ga`; Debian legt den Gastagenten nach `/usr/sbin/qemu-ga`. Das # Paket WAR eingebaut (`systemctl enable qemu-guest-agent` im selben Lauf hätte # eine fehlende Unit nicht aktivieren können), die Prüfung sah nur an der # falschen Stelle nach. # # Ein Pfad ist eine Vermutung darüber, wie ein Paket seine Dateien verteilt. # `/var/lib/dpkg/info/.list` ist die Aussage des Pakets selbst — und # genau die Frage, die hier gestellt gehört: hat apt es still übergangen? # # Ein Verzeichnis-Listing für alle Pakete zusammen, nicht eines pro Paket: jeder # virt-ls-Aufruf startet eine eigene Appliance. missing_packages() { _installed="$1" shift for _package in "$@"; do printf '%s\n' "$_installed" | grep -qx "${_package}.list" || printf '%s ' "$_package" done } # Prüft im fertigen Abbild nach, dass die vier Fallen wirklich zu sind. # # Nach dem Einbauen, nicht davor: `virt-customize` meldet auch dann Erfolg, wenn # apt ein Paket stillschweigend nicht installiert hat. verify_image_contents() { _image="$1" _installed="$(virt-ls -a "$_image" /var/lib/dpkg/info 2>/dev/null)" if [ -z "$_installed" ]; then log 'Die dpkg-Datenbank im Abbild ist nicht lesbar — der Inhalt ist nicht prüfbar' return 1 fi # FALLE 3 (qemu-guest-agent — sonst hängt jede Bereitstellung an # WaitForGuestAgent bis zum Zeitablauf) und FALLE 4 (cloud-guest-utils # bringt growpart — ohne das läge jedes gekaufte Kontingent über einem # Dateisystem, das nie gewachsen ist), dazu das Compose-Plugin, ohne das # `docker compose up` im Gast kein Kommando ist. _missing="$(missing_packages "$_installed" qemu-guest-agent cloud-guest-utils docker-compose-plugin docker-ce)" if [ -n "$_missing" ]; then log "Diese Pakete sind NICHT im Abbild: ${_missing}— apt hat sie still übergangen (Falle 3/4)" return 1 fi _compose_in_image="$(virt-cat -a "$_image" /opt/nextcloud/docker-compose.yml 2>/dev/null || true)" if [ -z "$_compose_in_image" ]; then log '/opt/nextcloud/docker-compose.yml fehlt im Abbild' return 1 fi # FALLE 1, und die einzige, die kein Paket ist: es geht um den INHALT der # Datei, die wir selbst hineinkopiert haben. if ! printf '%s' "$_compose_in_image" | grep -qE '^[[:space:]]*user:[[:space:]]*www-data[[:space:]]*$'; then log 'Die Compose-Datei im Abbild hat kein `user: www-data` (Falle 1) — alle occ-Aufrufe schlügen fehl' return 1 fi log 'Abbildinhalt in Ordnung: qemu-guest-agent, cloud-guest-utils, Docker samt Compose-Plugin, user: www-data' return 0 } # Legt die VM an, hängt die Platte ein und macht eine Vorlage daraus. create_proxmox_template() { _image="$1" _storage="$2" _vmid="$CLUPILOT_TEMPLATE_VMID" if qm status "$_vmid" >/dev/null 2>&1; then log "VMID ${_vmid} existiert bereits — wird ersetzt" qm destroy "$_vmid" --purge >/dev/null 2>&1 || true fi qm create "$_vmid" \ --name "$CLUPILOT_TEMPLATE_NAME" \ --memory 4096 \ --cores 2 \ --net0 "virtio,bridge=vmbr0" \ --scsihw virtio-scsi-single \ --ostype l26 \ --agent enabled=1 \ --serial0 socket \ --vga serial0 \ >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'qm create fehlgeschlagen'; return 1; } # Platte importieren UND einhängen, in einem Zug. # # Hier stand `qm importdisk` und danach ein `qm set --scsi0 # ${_storage}:vm-${_vmid}-disk-0`. Dieser Datenträgername gilt nur bei # BLOCK-Ablagen (local-lvm, local-zfs). Auf einer Verzeichnis-Ablage — genau # der, die ensure_image_storage gerade erst möglich macht, und die einzige # auf einem per Debian aufgesetzten Proxmox — heißt er # `local:9000/vm-9000-disk-0.qcow2`. Das Skript legte die Platte also an und # hängte sie unter einem Namen ein, den es dort nicht gibt. # # `--import-from` überlässt die Benennung Proxmox, das als einziges weiß, # wie sie auf dieser Ablage lautet. qm set "$_vmid" --scsi0 "${_storage}:0,import-from=${_image},discard=on,ssd=1" \ >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \ || { log 'Platte ließ sich nicht importieren'; return 1; } # cloud-init: darüber schreibt die Pipeline beim Klonen die .env in den Gast. qm set "$_vmid" --ide2 "${_storage}:cloudinit" --boot order=scsi0 \ >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'cloud-init-Laufwerk ließ sich nicht anlegen'; return 1; } qm template "$_vmid" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \ || { log 'qm template fehlgeschlagen'; return 1; } return 0 } # Ist es wirklich eine Vorlage? # # `VerifyVmTemplate` prüft heute nur die EXISTENZ von 9000, nicht das Merkmal — # der Handoff hält das als eigenen Fund fest. Eine VM, die zufällig 9000 heißt, # bestünde diese Prüfung. `template: 1` besteht nur eine Vorlage. template_is_really_a_template() { qm config "$CLUPILOT_TEMPLATE_VMID" 2>/dev/null | grep -qE '^template:[[:space:]]*1[[:space:]]*$' }