# 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' # 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. detect_vm_storage() { for _candidate in local-zfs local-lvm local; do if pvesm status --storage "$_candidate" >/dev/null 2>&1; then # Muss Platten aufnehmen können. `local` kann das oft NICHT — dort # liegen nur Abbilder und Sicherungen, und ein `qm importdisk` # dorthin scheitert erst nach dem Kopieren. if pvesm status --storage "$_candidate" --content images >/dev/null 2>&1; then printf '%s' "$_candidate" return 0 fi fi done # Sonst der erste Speicher, der Platten aufnimmt. pvesm status --content images 2>/dev/null | awk 'NR > 1 { print $1; exit }' } # Lädt das Cloud-Abbild und prüft es gegen Debians SHA512SUMS. fetch_cloud_image() { _target="$1" if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_target"; then log "Cloud-Abbild nicht ladbar: ${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" return 1 fi _sums="$(http_get "${CLUPILOT_CLOUD_IMAGE_BASE}/SHA512SUMS" || true)" if [ -z "$_sums" ]; then log 'SHA512SUMS von cloud.debian.org nicht abrufbar' 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}" return 1 fi _actual="$(sha512sum "$_target" | awk '{ print $1 }')" if [ "$_actual" != "$_expected" ]; then log 'Prüfsumme des Cloud-Abbilds stimmt nicht' rm -f "$_target" return 1 fi log 'Cloud-Abbild geladen, Prüfsumme stimmt' return 0 } # FALLE 2, geprüft statt angenommen. # # Zwei Aussagen: kein LVM, und die Root-Partition ist die letzte auf der Platte. # `virt-filesystems` beantwortet beide, ohne das Abbild einzuhängen. 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 # Die Wurzel ist die Partition mit der höchsten Nummer, wenn sie die letzte # ist. Anders gefragt: gibt es hinter der Root-Partition noch eine? _last_part="$(printf '%s\n' "$_fs" | awk '/^\/dev\/[a-z]+[0-9]+/ { print $1 }' | sort -V | tail -1)" _root_part="$(virt-filesystems --long -a "$_image" 2>/dev/null | awk '$0 !~ /swap/ && /ext4|xfs|btrfs/ { print $1; }' | sort -V | tail -1)" if [ -n "$_last_part" ] && [ -n "$_root_part" ] && [ "$_last_part" != "$_root_part" ]; then log "Die Root-Partition (${_root_part}) ist nicht die letzte auf der Platte (${_last_part}). GrowGuestFilesystem wächst nur die letzte." return 1 fi log "Abbildaufbau in Ordnung: kein LVM, Wurzel auf ${_root_part:-(unbestimmt)} als letzte Partition" 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" # Ohne diese Zeile hängt jede Bereitstellung an WaitForGuestAgent bis zum # Zeitablauf — Falle 3. _packages='qemu-guest-agent,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 } # Prüft im fertigen Abbild nach, dass die drei 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" if ! virt-ls -a "$_image" /usr/bin 2>/dev/null | grep -q '^qemu-ga$'; then log 'qemu-guest-agent ist NICHT im Abbild (Falle 3) — jede Bereitstellung hinge an WaitForGuestAgent' 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 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 if ! virt-ls -a "$_image" /usr/libexec/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then if ! virt-ls -a "$_image" /usr/lib/docker/cli-plugins 2>/dev/null | grep -q 'docker-compose'; then log 'Das Compose-Plugin ist nicht im Abbild' return 1 fi fi log 'Abbildinhalt in Ordnung: qemu-guest-agent, 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 als scsi0 einhängen. qm importdisk "$_vmid" "$_image" "$_storage" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \ || { log 'qm importdisk fehlgeschlagen'; return 1; } qm set "$_vmid" --scsi0 "${_storage}:vm-${_vmid}-disk-0,discard=on,ssd=1" \ >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'Platte ließ sich nicht einhängen'; 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:]]*$' }