Build the golden template so nobody has to remember how

All three Block A traps are CHECKED here rather than assumed, because each one
has already cost a paid order and none of them shows a symptom until it is too
late.

Trap 2 is checked before anything is built: virt-filesystems is asked whether
the image uses LVM and whether root is the last partition, and the section
refuses if either is wrong. Debian's cloud image satisfies both by construction,
which is exactly why it is the base — but "satisfies it by construction" is a
claim about an artefact somebody else builds, and it gets verified rather than
trusted.

Traps 1 and 3 are checked AFTER the image is customised, because virt-customize
reports success even when apt quietly did not install something. qemu-guest-agent
has to be in /usr/bin or every provisioning run hangs in WaitForGuestAgent until
it times out, and the compose file inside the image has to carry `user:
www-data` or every occ call fails — including the acceptance check that decides
whether a customer's instance is usable.

The template is verified by its ATTRIBUTE, not its existence. VerifyVmTemplate
checks only that 9000 is there, which a VM that merely happens to be numbered
9000 also passes; `template: 1` is passed only by a template.

Docker comes from Docker's own repository, not Debian's. docker.io ships no
compose plugin and Debian's docker-compose is the old Python one, which does not
read this file at all.

The compose file is deliberately customer-independent: no password, no name, no
domain. Everything variable arrives in /opt/nextcloud/.env, written into the
guest by cloud-init at clone time. Baking a password into an image copies it
onto every host and into every instance, and leaves it there long after the
customer has changed it.

OVERWRITEPROTOCOL, OVERWRITEHOST and TRUSTED_PROXIES are set because TLS ends at
Traefik on the host. Without them Nextcloud builds its own URLs with http://,
the login loops, and WebDAV clients get handed an address that does not exist.

Storage is discovered rather than named: local-zfs is what the PVE installer
creates on ZFS, local-lvm on ext4, and `local` frequently cannot hold disks at
all — an importdisk there fails only after the copy.

Verified without hardware: dash-clean; the compose file parses, carries `user:
www-data` on the app service, publishes 80, and every credential is a ${...}
reference rather than a literal. Step 2 unticked, and it is the one the plan is
most insistent about: the template must actually be cloned, started, and asked
`occ status` as www-data. Without that clone it is not proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 20:16:50 +02:00
parent a25e9c2fe6
commit 5d25018cc6
4 changed files with 402 additions and 3 deletions

View File

@ -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:

View File

@ -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.'

View File

@ -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:]]*$'
}

View File

@ -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`
---