diff --git a/deploy/bootstrap/assets/docker-compose.yml b/deploy/bootstrap/assets/docker-compose.yml new file mode 100644 index 0000000..831abb6 --- /dev/null +++ b/deploy/bootstrap/assets/docker-compose.yml @@ -0,0 +1,76 @@ +# Die Nextcloud-Aufstellung, die in der goldenen Vorlage liegt. +# +# Landet als /opt/nextcloud/docker-compose.yml im Abbild. Sie ist bewusst +# KUNDENUNABHÄNGIG: kein Kennwort, kein Name, keine Domain steht hier. Alles +# Veränderliche kommt aus /opt/nextcloud/.env, die die Pipeline beim Klonen über +# cloud-init schreibt. +# +# Ein Passwort in ein Abbild zu backen hieße, es auf jeden Host und in jede +# Kundeninstanz zu kopieren — und es dort auch dann noch liegen zu haben, wenn +# der Kunde längst ein anderes gesetzt hat. + +services: + db: + image: mariadb:11.4 + restart: always + # READ-COMMITTED und Zeilen-Binlog sind die Einstellungen, die Nextcloud + # selbst verlangt; ohne sie meldet die Verwaltungsübersicht dauerhaft eine + # Warnung und einzelne Sperren verhalten sich anders als dokumentiert. + command: >- + --transaction-isolation=READ-COMMITTED + --binlog-format=ROW + --log-bin=binlog + volumes: + - db:/var/lib/mysql + environment: + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_DATABASE: nextcloud + MYSQL_USER: nextcloud + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env} + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 12 + + app: + image: nextcloud:apache + restart: always + + # FALLE 1 aus Block A, nicht verhandelbar: ohne diese Zeile schlagen ALLE + # occ-Aufrufe fehl. Die Bereitschaftsprüfung der Pipeline ruft `occ status`, + # und ein Abbild ohne diese Zeile besteht sie nie. + user: www-data + + ports: + - "80:80" + depends_on: + db: + condition: service_healthy + volumes: + - nextcloud:/var/www/html + environment: + MYSQL_HOST: db + MYSQL_DATABASE: nextcloud + MYSQL_USER: nextcloud + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD fehlt in .env} + + # Sind diese beiden gesetzt, installiert sich Nextcloud beim ersten Start + # selbst. Genau dafür sind sie da — der Kunde soll keine + # Einrichtungsmaske sehen. + NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:?NEXTCLOUD_ADMIN_USER fehlt in .env} + NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:?NEXTCLOUD_ADMIN_PASSWORD fehlt in .env} + + NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS:-} + + # TLS endet bei Traefik auf dem Host, nicht hier. Ohne diese drei baut + # Nextcloud seine eigenen Adressen mit http:// — die Anmeldung dreht sich + # dann im Kreis, und WebDAV-Zugänge zeigen auf eine Adresse, die es so + # nicht gibt. + OVERWRITEPROTOCOL: https + OVERWRITEHOST: ${NEXTCLOUD_PRIMARY_DOMAIN:-} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-172.16.0.0/12} + +volumes: + db: + nextcloud: diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index 7bf05ae..7870a38 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -785,6 +785,72 @@ section_traefik_running() { return 0 } +# --------------------------------------------------------------------------- +# Abschnitt 8: template_built — die goldene Vorlage +# --------------------------------------------------------------------------- +# +# Jede der drei Fallen aus Block A wird hier GEPRÜFT, nicht angenommen. Der +# Handoff nennt den Preis: der erste bezahlte Auftrag starb *nach* der Zahlung, +# weil die Vorlage nur so aussah, als wäre sie fertig. +section_template_built() { + export DEBIAN_FRONTEND=noninteractive + if ! apt-get install -y libguestfs-tools >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1; then + CLUPILOT_SECTION_NOTE='libguestfs-tools ließ sich nicht installieren — ohne virt-customize entsteht keine Vorlage' + return 1 + fi + + _storage="$(detect_vm_storage)" + if [ -z "$_storage" ]; then + CLUPILOT_SECTION_NOTE='kein Proxmox-Speicher gefunden, der Platten aufnimmt' + return 1 + fi + + _image="${CLUPILOT_WORK_DIR}/${CLUPILOT_CLOUD_IMAGE}" + if [ ! -f "$_image" ] && ! fetch_cloud_image "$_image"; then + CLUPILOT_SECTION_NOTE='Debian-Cloud-Abbild nicht ladbar oder Prüfsumme falsch' + return 1 + fi + + # Falle 2, VOR dem Umbauen: taugt der Aufbau überhaupt? + if ! verify_image_layout "$_image"; then + CLUPILOT_SECTION_NOTE='Der Aufbau des Cloud-Abbilds erfüllt Falle 2 nicht (LVM oder Wurzel nicht als letzte Partition)' + return 1 + fi + + _compose="${CLUPILOT_BOOTSTRAP_DIR}/assets/docker-compose.yml" + if [ ! -f "$_compose" ]; then + CLUPILOT_SECTION_NOTE="assets/docker-compose.yml fehlt unter ${CLUPILOT_BOOTSTRAP_DIR}" + return 1 + fi + + if ! customise_cloud_image "$_image" "$_compose"; then + CLUPILOT_SECTION_NOTE="Abbild ließ sich nicht umbauen; siehe ${CLUPILOT_WORK_DIR}/virt-customize.log" + return 1 + fi + + # Fallen 1 und 3, NACH dem Umbauen: virt-customize meldet auch dann Erfolg, + # wenn apt ein Paket stillschweigend nicht installiert hat. + if ! verify_image_contents "$_image"; then + CLUPILOT_SECTION_NOTE='Das fertige Abbild erfüllt Falle 1 oder Falle 3 nicht' + return 1 + fi + + if ! create_proxmox_template "$_image" "$_storage"; then + CLUPILOT_SECTION_NOTE="Vorlage ließ sich nicht anlegen; siehe ${CLUPILOT_WORK_DIR}/qm.log" + return 1 + fi + + # Das Merkmal, nicht die Existenz. Eine VM, die zufällig 9000 heißt, ist + # keine Vorlage — und `VerifyVmTemplate` prüft heute genau das Falsche. + if ! template_is_really_a_template; then + CLUPILOT_SECTION_NOTE="VMID ${CLUPILOT_TEMPLATE_VMID} existiert, meldet aber nicht template: 1" + return 1 + fi + + CLUPILOT_SECTION_NOTE="Vorlage ${CLUPILOT_TEMPLATE_VMID} auf ${_storage}, alle drei Fallen geprüft" + return 0 +} + main() { parse_arguments "$@" @@ -797,6 +863,8 @@ main() { . "${CLUPILOT_BOOTSTRAP_DIR}/lib/network.sh" # shellcheck source=lib/traefik.sh . "${CLUPILOT_BOOTSTRAP_DIR}/lib/traefik.sh" + # shellcheck source=lib/template.sh + . "${CLUPILOT_BOOTSTRAP_DIR}/lib/template.sh" init_reporting if [ "$CLUPILOT_RESUMING" = '1' ]; then @@ -843,7 +911,10 @@ main() { die 'Abbruch: ohne Traefik erreicht kein Kunde je seine Instanz.' fi - # run_section template_built section_template_built # Task 8 + if ! run_section template_built section_template_built; then + die 'Abbruch: ohne bewiesene Vorlage stirbt der erste bezahlte Auftrag nach der Zahlung.' + fi + # run_section registered section_registered # Task 9 log 'Proxmox abgenommen, Quellen in Ordnung. Die Abschnitte ab Task 5 sind noch nicht eingehängt.' diff --git a/deploy/bootstrap/lib/template.sh b/deploy/bootstrap/lib/template.sh new file mode 100644 index 0000000..69c6915 --- /dev/null +++ b/deploy/bootstrap/lib/template.sh @@ -0,0 +1,252 @@ +# 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:]]*$' +} diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md index 4369b68..c64204a 100644 --- a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md @@ -410,7 +410,7 @@ fertig. ### Task 8: `template_built` — die goldene Vorlage -- [ ] **Step 1: Schreiben** +- [x] **Step 1: Schreiben** Aus einem Debian-13-Cloud-Image, auf `template_vmid 9000`: @@ -433,7 +433,7 @@ fest, dass `VerifyVmTemplate` heute nur die **Existenz** prüft, nicht das Merkm aus 9000 erzeugen, starten, `occ status` als `www-data` aufrufen. **Ohne diesen Klon ist die Vorlage nicht bewiesen**, und der Handoff nennt genau diesen Fall: der erste bezahlte Auftrag starb sonst *nach* der Zahlung. -- [ ] **Step 3: Committen.** `Build the golden template so nobody has to remember how` +- [x] **Step 3: Committen.** `Build the golden template so nobody has to remember how` ---