# Vorlagenbau in der Übernahme-Pipeline (`BuildVmTemplate`)

*Entwurf, 1. August 2026*

## Das Loch

`VerifyVmTemplate` **meldet**, dass eine Vorlage fehlt, und baut ausdrücklich
keine. Das war richtig, solange niemand entschieden hatte, was in die goldene
Vorlage gehört — inzwischen ist das entschieden und steht in
`deploy/bootstrap/lib/template.sh`. Damit ist die Übernahme eines Hosts heute an
genau einer Stelle unfertig: der Betreiber muss die Vorlage von Hand bauen oder
von einem anderen Knoten kopieren, sonst bleibt der Lauf bei `verify_vm_template`
stehen.

Das Abnahmeziel ist eine frische Debian-13-Maschine, Host anlegen, zusehen,
`active` — ohne einen Handgriff. Der Vorlagenbau ist der Schritt, der dazwischen
fehlt.

## Die Entscheidung: das vorhandene Skript ausführen, nicht in PHP nachbauen

`template.sh` sind 252 geprüfte Zeilen, die drei Fallen abdecken, die je einen
bezahlten Auftrag gekostet haben. Eine zweite Fassung in PHP wäre genau der
Fehler, den die Wege-Entscheidung vom 1. August vermeiden sollte: zwei
Installationen, die bei jeder Proxmox-Version nachgezogen werden müssen, und die
zweite fällt erst auf, wenn jemand sie benutzt.

Also: **die Datei wortgleich per SSH auf den Host laden und dort ausführen.**
Was heute nur `clupilot-bootstrap.sh` konnte, kann danach die Pipeline — aus
derselben Datei.

## Aufbau

### 1. `deploy/bootstrap/lib/template.sh` — erweitert, nicht kopiert

Fünf Änderungen an der einen Fassung. Der Rettungssystem-Weg erbt sie
automatisch, sollte er je wiederbelebt werden.

**`ensure_image_storage()` — neu.** Auf einem per Debian aufgesetzten Proxmox
ist `local` eine Verzeichnis-Ablage, deren Inhaltsliste standardmäßig **keine
Platten** enthält. `detect_vm_storage()` findet dann nichts und der Bau stirbt,
bevor er anfängt — und `RegisterCapacity` meldete hinterher Kapazität 0. Das
wäre ein Host, der in der Liste fertig aussieht und auf den nie ein Kunde gelegt
werden kann: dieselbe Sorte Fehler wie die fehlende Vorlage, nur leiser.

Die Funktion greift **nur ein, wenn keine Ablage Platten annimmt**:

- Erst `detect_vm_storage()`. Liefert sie etwas, passiert nichts. Ein Host mit
  `local-lvm` oder `local-zfs` kommt also gar nicht erst hierher — die nehmen
  Platten längst, und an `local` wird dort nicht gerührt.
- Sonst: die **vorhandene** Inhaltsliste aus `/etc/pve/storage.cfg` lesen und
  `images` **anhängen**. Eine feste Liste zu schreiben würde löschen, was
  jemand auf diesem Host ergänzt hat.
- Geschrieben wird über **`pvesm set`**, nie in die Datei. `/etc/pve` ist
  pmxcfs; ein von Hand geschriebener Eintrag umgeht Prüfung und Neuladen.

**`check_build_space()` — neu.** Abbild, Arbeitskopie und importierte Platte
sind zusammen ein Vielfaches der Abbildgröße. Ein `df` vorab mit klarer Absage
ist billiger als ein Abbruch nach zwanzig Minuten mitten im `virt-customize`.

**`fetch_cloud_image()` lädt nach `.part`** und benennt erst um, wenn die
SHA512-Summe stimmt. Bisher schrieb sie direkt ans Ziel; ein abgebrochener Lauf
hinterließ eine abgeschnittene Datei, die der nächste wegen `[ ! -f "$_image" ]`
ungeprüft weiterbenutzt hätte.

**`customise_cloud_image()` installiert `cloud-guest-utils`** —
`GrowGuestFilesystem` ruft `growpart` auf, und installiert hat es nie jemand. Es
funktioniert heute, weil Debians Cloud-Abbild es zufällig mitbringt. Fällt es
dort einmal heraus, bekommt jeder Kunde stillschweigend ein Kontingent, das nie
angewendet wird — bemerkt wird das erst, wenn eine Platte voll ist, die laut
Rechnung dreimal so groß sein sollte. `verify_image_contents()` weist es danach
als **vierte Falle** nach. Installieren *und* nachweisen: `ping` war auch
„bringt das System doch mit", und `VM.Monitor` war auch „das gibt es doch".

**`create_proxmox_template()` importiert über `qm set --import-from`** statt
`qm importdisk` mit anschließend geratenem Datenträgernamen. Der bisherige Code
hängte die Platte als `${storage}:vm-${vmid}-disk-0` ein — dieser Name gilt nur
bei **Block**-Speichern. Auf einer Verzeichnis-Ablage heißt der Datenträger
`local:9000/vm-9000-disk-0.qcow2`, und genau diese Ablage ist der Fall, den
`ensure_image_storage()` gerade erst möglich macht. Das Skript hätte die Platte
angelegt und dann unter einem Namen eingehängt, den es dort nicht gibt.
`--import-from` überlässt die Benennung Proxmox, das als einziges weiß, wie sie
auf dieser Ablage lautet.

### 2. `deploy/bootstrap/lib/template-run.sh` — neu, der Treiber

Der einzige Shell-Neubau. Er trägt:

- einen knappen Vorspann mit den vier Helfern, die `template.sh` erwartet
  (`log`, `die`, `http_download`, `http_get`). Bewusst eine eigene, kleine
  Fassung statt `clupilot-bootstrap.sh` anzufassen: das ist der stillgelegte
  Weg, und die Helfer sind curl-Hüllen ohne Entwurfsentscheidung — die 252
  Zeilen mit den Fallen bleiben die eine Fassung, und nur darum ging es.
- die Phasen in Reihe: Platz prüfen → `libguestfs-tools` → Ablage sichern →
  Abbild laden → Aufbau prüfen (Falle 2) → **Arbeitskopie ziehen** → umbauen →
  Inhalt prüfen (Fallen 1, 3, 4) → je VMID Vorlage anlegen → `template: 1`
  nachweisen.
- die Statusdateien.

**Die Arbeitskopie** ist der Grund, warum ein Wiederanlauf nichts erbt: das
geprüfte Abbild bleibt unangetastet liegen, umgebaut wird eine Kopie. Ein
Abbruch mitten im `virt-customize` hinterlässt sonst ein halb umgebautes Abbild,
das der nächste Lauf für fertig heruntergeladen hält.

**Statusprotokoll** im Arbeitsverzeichnis `/var/lib/clupilot/template-build`:

| Datei | Inhalt |
|---|---|
| `state` | `running` \| `ok` \| `failed` |
| `pid` | PID des Treibers |
| `phase` | laufende Phase, für die Fortschrittszeile im Protokoll |
| `note` | Grund im Fehlerfall |
| `build.log` | alles |

### 3. `App\Provisioning\Steps\Host\BuildVmTemplate`

Zwischen `VerifyProxmoxApi` und `VerifyVmTemplate`: bauen, dann prüfen. Vor
`RegisterCapacity`, damit die Ablagenerweiterung schon gewirkt hat, wenn die
Kapazität gezählt wird.

Ablauf je Aufruf:

1. Kein Katalog veröffentlicht → `advance()`. Dieselbe Ausnahme, die
   `VerifyVmTemplate` schon macht: eine Installation, die ihren ersten Host
   übernimmt, bevor sie Pakete veröffentlicht, verlangt keine Vorlage.
2. Welche der verlangten VMIDs melden **nicht** `template: 1`? Keine → `advance()`,
   ohne etwas anzufassen. Die Abkürzung fragt bewusst nach dem Merkmal, nicht
   nach der Existenz: ein abgebrochener Bau hinterlässt eine VM mit der Nummer
   9000, und eine Existenzprüfung sagte dazu „passt".
3. Gebaut wird **nur die Fehlliste**, nie alles Verlangte. `create_proxmox_template`
   räumt eine VMID weg, bevor es sie anlegt — zwei Pakete mit zwei Vorlagen, von
   denen eine steht, hieße die stehende auf dem Weg zu zerstören und bei einem
   Fehlschlag ohne sie dazustehen.
4. Statusdateien lesen. Sie überleben den Lauf, der sie geschrieben hat, also
   entscheidet zuerst: **hat dieser Lauf überhaupt etwas gestartet?** Das sagt
   die Frist im Run-Kontext.

**Status aus einem früheren Lauf** (keine Frist im Kontext):

| `state` | Prozess | Ergebnis |
|---|---|---|
| `running` | lebt | **übernehmen** — `poll(30)`, eigene Frist setzen |
| alles andere | — | Dateien hochladen, `nohup setsid` starten, `poll(30)` |

Ein `ok` von gestern beantwortet keine Frage, die dieser Lauf gestellt hat. Als
eigenes Ergebnis gelesen hieße es „der Bau, den ich angestoßen habe, ist fertig
geworden, ohne zu liefern, worum ich gebeten habe" — und das ist der gewöhnliche
Fall an dem Tag, an dem der Katalog eine zweite Vorlage bekommt. Ein *lebender*
Bau ist die Ausnahme: er baut dasselbe, also wird er übernommen statt daneben
ein zweiter gestartet.

**Status aus diesem Lauf** (Frist gesetzt):

| `state` | Prozess | Ergebnis |
|---|---|---|
| — | — | **`fail`** — der Start hat nicht gegriffen |
| `running` | lebt (`kill -0`) | `poll(30, phase)` |
| `running` | tot | **sofort `fail`** |
| `ok` | — | `fail` — das Skript meldet fertig, die API widerspricht |
| `failed` | — | `fail(note)` mit den letzten Protokollzeilen |

Zeile 3 ist der Punkt, an dem beides schiefgeht, wenn man ihn weglässt: startet
der Schritt neu, obwohl noch ein Treiber läuft, laufen zwei `virt-customize` auf
derselben Datei und beide legen VMID 9000 an. Und pollt er weiter, weil in der
Datei `running` steht, obwohl der Prozess nach einem Neustart weg ist, wartet er
fünfundvierzig Minuten gegen eine Leiche. **„Läuft noch" heißt: der Prozess
lebt** — nicht: in der Datei steht `running`.

Eigene Frist von 45 Minuten im Run-Kontext, `maxDuration()` 3600 darüber, damit
die eigene Frist zuerst greift und nicht der allgemeine Schritt-Zeitablauf —
dieselbe Form wie `RebootIntoPveKernel`.

**Aufgeben beendet erst, dann räumt es auf.** Der Fristablauf trifft einen
Prozess, der noch läuft; nur die Statusdateien zu löschen machte den Schritt
wiederholbar und ließe den alten Bau weiterlaufen — dieselbe Kollision, von der
anderen Seite. Beendet wird die **Prozessgruppe** (`kill -TERM -$P`, dann
`-KILL`), weil der Treiber die meiste Zeit auf ein `virt-customize` wartet, das
sonst allein weitermahlt. Dass die PID zugleich die Prozessgruppe ist, ist der
Grund für `setsid` beim Start — und der Grund, warum das Skript seine PID selbst
per `$$` hinterlegt statt `$!` auf der Startseite: mit `setsid` dazwischen kann
`$!` auf einen Prozess zeigen, den es schon nicht mehr gibt. Der Startbefehl
wartet deshalb bis zu 15 Sekunden auf die Datei, sonst hielte der erste Poll
einen gesunden Bau für tot. Gegen eine wiederverwendete PID prüft der
Abbruch `/proc/$P/cmdline`.

Beim `fail` verschwinden Statusdateien **und** die Frist im Kontext, damit ein
Wiederholen aus der Konsole wieder ein erster Besuch ist; das geprüfte Abbild
bleibt liegen und muss nicht neu geladen werden.

### 4. `ProxmoxClient::isTemplate()` — neu

Zwei Anfragen, weil die falsche Antwort hier **etwas zerstört**: `false` heißt
für `BuildVmTemplate` „Vorlage fehlt", und der Bau fängt mit `qm destroy
--purge` an. „Nicht da" und „konnte nicht fragen" dürfen sich deshalb nicht
ähnlich sehen — und am Rückgabecode sind sie nicht zu unterscheiden: Proxmox
beantwortet die Konfiguration einer nicht vorhandenen VM mit **500**, demselben
Code wie einen Knoten in Not.

Also klärt die **VM-Liste** (`GET /nodes/{node}/qemu`) die Abwesenheit und sonst
nichts: fehlt die VMID in einer erfolgreichen Liste, ist sie wirklich nicht da.
Alles unterhalb einer erfolgreichen Liste **wirft** und landet im
Wiederholungs-Zweig des Aufrufers — die vorhandene Vorlage überlebt. Erst danach
klärt das **Konfigurationsdokument** (`GET …/{vmid}/config`) das Merkmal
`template`.

Benutzt von `VerifyVmTemplate` **und** von `VmTemplateCheck`. Beide prüften nur
`vmExists`, beide stellen dieselbe Frage — die eine bei der Übernahme, die
andere vor dem Verkauf —, und beide hätten eine VM durchgelassen, die zufällig
9000 heißt.

### 5. `PlanVersion::requiredTemplateVmids()` — neu

Dieselbe Fensterlogik stand in `VerifyVmTemplate` und `VmTemplateCheck` und wäre
mit `BuildVmTemplate` zum dritten Mal abgeschrieben worden. Drei Kopien driften,
und die abgedriftete fände ein Kunde.

## Was dieser Entwurf nicht tut

- **`vmbr0` anlegen.** `qm create --net0 virtio,bridge=vmbr0` schreibt nur
  Konfigurationstext und stört sich nicht an einer fehlenden Brücke. Die Brücke
  ist ein eigener offener Punkt in `ConfigureProxmox`.
- **Die Proxmox-Version fixieren.** Eigener offener Punkt.
- **Den Rettungssystem-Weg wiederbeleben.** Er bleibt liegen; er erbt die
  Korrekturen an `template.sh` nur, falls er je zurückkommt.

## Prüfung

Pest, vollständig mit Attrappen, wie der Rest der Pipeline:

- Kein veröffentlichter Katalog → `advance`, nichts hochgeladen.
- Vorhandene echte Vorlage → `advance`, kein Start.
- VM 9000 existiert, ist aber **keine** Vorlage → es wird gebaut.
- Erster Aufruf lädt alle drei Dateien **wortgleich** hoch (Byte für Byte gegen
  die Repo-Dateien geprüft) und startet abgekoppelt → `poll`.
- Zwei Vorlagen verlangt, eine steht → nur die fehlende landet in `env`.
- `running` + lebender Prozess → `poll`, **kein** zweiter Start.
- `running` + toter Prozess → `fail`, nicht `poll`.
- `failed` → `fail` mit dem Grund aus `note`; `state` wird entfernt.
- `ok`, aber `template: 1` fehlt → `fail`.
- Frist überschritten → `fail`, **und** die Prozessgruppe wird beendet.
- Start hinterließ keinen Status → `fail` statt endlos neu zu starten.
- Nach einem `fail` ist die Frist weg, damit „Wiederholen" wirklich neu anfängt.
- Status aus einem früheren Lauf: `ok` → neu bauen; lebendes `running` →
  übernehmen statt danebenstarten.
- `isTemplate`: 502 → **wirft** (nicht „fehlt"); VMID nicht in der Liste →
  `false`; `template: 1` → `true`; gewöhnliche VM mit der Nummer → `false`.
- `VerifyVmTemplate`/`VmTemplateCheck`: eine VM ohne `template: 1` besteht nicht
  mehr.
- **Jeder Befehl, den der Schritt absetzt, geht durch `sh -n`.** Keine andere
  Prüfung führt diese Shell je aus — ein verrutschtes Anführungszeichen käme
  sonst zuerst auf einem echten Host zur Ausführung, und zwar in dem Schritt,
  der VMs zerstört und neu anlegt.

Dazu `sh -n` über alle drei Shell-Dateien, und die awk-Auswertung von
`storage.cfg` gegen eine echte Beispieldatei durchgespielt (`CLUPILOT_STORAGE_CFG`
ist genau dafür überschreibbar).

## Was hier NICHT geprüft ist

Nichts davon lief je gegen einen echten Proxmox-Host. Alle Tests sind Attrappen,
und die Stellen mit dem größten Risiko sind genau die, die eine Attrappe nicht
abbilden kann: `qm set --import-from` auf einer Verzeichnis-Ablage, `virt-customize`
unter PVE 9, `pvesm set` gegen pmxcfs, und ob `nohup setsid` den Bau wirklich
überleben lässt, wenn phpseclib den Kanal schließt. Die erste Übernahme auf
echter Hardware ist die Abnahme, nicht dieser Testlauf.
