CluPilotCloud/docs/handoffs/2026-07-30-real-run-handoff.md

271 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Handoff — vom grünen Test zum ersten echten Durchlauf
**Stand:** 2026-07-30, `main` auf `v1.3.44`, **1686 Tests grün.**
**Zweck:** In dieser Nacht wurden Host-Onboarding, VM-Erstellung und der Stripe-Prozess
vollständig geprüft und der größte Teil der Funde behoben. Was bleibt, ist unten
beschrieben. Die Kernaussage in einem Satz: **die Testsuite war grün, und trotzdem
hätte kein Host durchinstalliert und keine VM ausgeliefert werden können.**
---
## 0. Die Lektion, die den ganzen Rest erklärt
Alle Funde waren gegen eine grüne Suite unsichtbar, und zwar systematisch:
- `HostOnboardingEndToEndTest` fährt die ganze Pipeline gegen `FakeRemoteShell`,
dessen Standardantwort **Erfolg** ist. Der Test beweist, dass die Zustandsmaschine
richtig durchzählt — über das Verhalten irgendeines Befehls sagt er nichts.
- Rund ein Dutzend Tests schrieben in `config('provisioning.plans.*')`, einen
Schlüssel, den es seit dem Katalog-Umbau nicht mehr gibt (und es existiert ein
Test, der genau das behauptet: `expect(config('provisioning.plans'))->toBeNull()`).
Jede dieser Zeilen war wirkungslos; die Tests waren aus dem falschen Grund grün.
- Mehrere Tests prüften eine Spalte, die der Code eine Zeile vorher selbst
geschrieben hatte.
**Konsequenz für die nächste Session:** Ein Testlauf ist keine Abnahme. Jeder
Arbeitsblock unten endet mit einem Schritt auf echter Hardware, nicht mit „grün".
---
## 1. Entscheidungen aus dem Brainstorming (verbindlich)
| Frage | Entscheidung |
|---|---|
| Woher kommt die Software im Gast? | **Vorlage bringt Docker + Compose mit, Nextcloud installiert sich beim ersten Start** aus `NEXTCLOUD_ADMIN_*`, die die Pipeline in die `.env` schreibt. Vorlage bleibt kundenunabhängig, Passwörter werden nie in ein Image gebacken. |
| Proxmox-Version | **PVE 9 auf Debian 13 trixie.** Die Codename-Tabelle in `InstallProxmoxVe` kennt beides; PVE 8/bookworm kann bleiben, getestet und unterstützt wird 9. |
| Traefik | **Systemdienst (Binary + systemd-Unit), pro Host**, installiert *und geprüft* von einem neuen Pipeline-Schritt. Kein Docker auf dem Hypervisor — nftables bleibt einziger Eigentümer der Host-Firewallregeln. |
| `vmbr0` | **Vollautomatisch**, mit Selbstrücknahme-Zeitgeber (siehe Block B). |
| Testumgebung | **Ein dedizierter Server mit Debian 13**, neu aufsetzbar. Kein zweiter Rechner nötig. |
| Testpaket | Eigene Paketfamilie **„Test"**: 20 GB Platte, 1 GB RAM, 1 Kern, 5 GB Kontingent. Nach dem Beweis wieder aus dem Verkauf nehmen. |
| ACME | HTTP-01 pro Host. **Kein** Wildcard/DNS-01 — pro Kunde entsteht genau ein Zertifikat, unabhängig von der Host-Zahl. |
**Nicht geeignet:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung,
also `/dev/kvm` nicht vorhanden, also startet kein Gast. Unabhängig von der Größe.
Bei netcup-VPS ist es produktabhängig; entscheidend ist
`grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`.
---
## 2. Was heute Nacht behoben und ausgeliefert wurde
Sechs Commits, `8440266``9da1358`, Tag `v1.3.44`.
### Stripe / Geld
- **Nichts konnte ein Stripe-Abo beenden.** Kündigung und Widerruf schrieben nur
lokale Zustände; Stripe erfuhr nie davon, und die nächste Abbuchung wurde
**als echte Rechnung aus der lückenlosen Serie ausgestellt und versendet**
monatlich, unbegrenzt, für eine abgeschaltete Leistung. Jetzt: Kündigung zum
Periodenende, Widerruf sofort, und eine Rechnung wird nur noch ausgestellt, wenn
ihr Abrechnungszeitraum **vor** dem Vertragsende begann.
- Kündigung lief auf **Monatstakt auch bei Jahresverträgen** (bis zu elf bezahlte
Monate verloren). Jetzt aus `current_period_end`.
- **Einrichtungsgebühr** wurde überall beworben und **nie berechnet** — jetzt eine
Einmalposition am Checkout, brutto, auf der ersten Rechnung.
- Das Kundenportal zeigte **fünf erfundene Rechnungen**, eine erfundene
Ausgabenkurve und eine erfundene nächste Abbuchung, während die echten Belege
existierten und nirgends im Portal auftauchten. Jetzt die echten.
- **Widerruf erstattet voll** (Ihre Entscheidung), Stornorechnung für **jede**
Abbuchung im Zeitfenster, nicht nur die erste.
- Ein Absturz nach dem Auftrags-Commit ließ einen bezahlten Kauf **dauerhaft
unfakturiert** — `resume()` holt das jetzt nach, idempotent.
- Klick auf „Upgrade" löschte stillschweigend eine vorgemerkte Abstufung und tat
sonst nichts. Behoben.
- **Reverse-Charge-Kunden zahlten 20 % zu viel**: ein Stripe-Preis für alle, also
Bruttobetrag auch für den, dem keine Steuer berechnet wird — und der Beleg wies
den ganzen Betrag als Netto aus. Jetzt **zwei Preise je Paket** (brutto/netto),
Auswahl an der Kasse über `TaxTreatment`.
- **UID-Prüfung gegen VIES** gebaut (vorher setzte niemand `vat_id_verified_at`,
Reverse Charge konnte also nie auslösen). Drei Ergebnisse: bei „Register nicht
erreichbar" wird **nichts** geschrieben.
### Host-Onboarding
- **Debian-Codename war fest auf `bookworm`** — ein heutiger Server hätte PVE-8-Quellen
gegen Debian 13 bekommen. Jetzt aus `/etc/os-release`, unbekannte Releases brechen ab.
- **WireGuard-Sackgasse:** Die Tunnel-IP wurde gespeichert, bevor der Tunnel bewiesen
war; danach wählte *jeder* Verbindungsversuch inklusive Konsolen-Wiederholung diesen
kaputten Tunnel, und der reparierende Code war unerreichbar. Rettung nur per
Datenbank-Handarbeit. Jetzt gilt der Tunnel erst mit bewiesenem Handshake.
- **wg0 hätte den Neustart nicht sicher überlebt** (`||`-Fallback verdeckte die
fehlende systemd-Aktivierung); **der Neustart konnte eine nicht mehr bootende
Maschine hinterlassen**; **`ConfigureProxmox` tat nichts** (kein `vmbr0`, Datacenter-
Firewall aus → die „nur 80/443"-Regeln der Kunden-VMs waren **wirkungslos**);
der Rolle fehlte **`Sys.Modify`** (jede Kundenbereitstellung wäre am Backup-Schritt
gestorben); nftables verwarf **jedes ICMP** (IPv6 und PMTUD gebrochen);
`api_token_ref` hing an `APP_KEY` statt am `SECRETS_KEY`.
- Neu: **`VerifyVmTemplate`** — ein Host geht nicht mehr auf „aktiv", wenn die
Vorlage fehlt. Vorher starb der erste bezahlte Auftrag *nach* der Zahlung.
### VM-Erstellung
- **Das 14-tägige Kapazitätsparken war unerreichbar:** Abfrageintervall 120 s gegen
Schritt-Zeitlimit 60 s, also lief jeder Wiedereinstieg in eine Zeitüberschreitung.
Ein bezahlter Auftrag ohne freien Host war nach **sechs Minuten** ein Fehlerfall.
Die ganze Warteschlange war toter Code.
- **Jeder `occ`-Aufruf lief als root** statt `www-data`; Nextcloud verweigert das.
Jetzt ein gemeinsamer Erbauer (`App\Support\NextcloudOcc`) plus ein Test, der
jede neue `docker compose exec`-Stelle außerhalb davon verbietet.
- Ein **einzelner zuckender HTTPS-Test verurteilte eine fertige Cloud** zum Fehlerfall.
- Eine nachgewiesene eigene Domain wurde **nie geroutet**, wenn zufällig ein Neustart
lief; gekaufter Speicher wurde **abgerechnet und nie geliefert**. Der „läuft schon
etwas?"-Schutz fragt jetzt, ob der laufende Lauf die Arbeit **tatsächlich abdeckt**.
- Die `address`-Pipeline lief in der falschen Reihenfolge (Zertifikat vor
vertrauenswürdigem Hostnamen → gültiges Zertifikat auf Nextclouds
„untrusted domain"-Fehlerseite).
- Eine geänderte Gast-IP war **unreparierbar** (502 ohne Weg zurück).
- Der Hetzner-DNS-Abgleich las **nur die erste Seite** → ab ~100 Einträgen ein
**zweiter A-Record** für denselben Namen.
---
## 3. Was noch zu tun ist — vier Blöcke, in dieser Reihenfolge
### Block A — Goldene VM-Vorlage (kritischer Pfad, blockiert alles)
Es gibt **keine** Vorlage auf `template_vmid 9000`. Ohne sie kann nichts ausgeliefert
werden. Zu tun:
- Debian-13-VM auf dem dedizierten Server, ~20 GB Platte, cloud-init-fähig
(`qemu-guest-agent` **muss** drin sein — `WaitForGuestAgent` hängt daran).
- Docker + Compose, `/opt/nextcloud/` mit `docker-compose.yml`, das
`NEXTCLOUD_ADMIN_USER`/`NEXTCLOUD_ADMIN_PASSWORD`/`MYSQL_PASSWORD` aus `.env` liest
und beim ersten Start selbst installiert.
- **`user: www-data` am `app`-Dienst** — sonst schlagen alle `occ`-Aufrufe fehl.
- Partition als **letzte** auf der Platte, **kein LVM**`GrowGuestFilesystem` kann
eine nicht-letzte Partition nicht vergrößern und erkennt LVM bewusst nicht
(es würde sonst ein Kontingent über ein nicht gewachsenes Dateisystem schreiben).
- Als Proxmox-Vorlage konvertieren, vmid 9000.
- **Schriftliches Runbook dazu**, versioniert im Repo. Die Vorlage ist ab dann Ihr
wichtigstes Artefakt und gehört nicht in mündliches Wissen.
### Block B — `vmbr0` vollautomatisch (riskantester Code des Vorhabens)
Eigener Schritt, eigener Abnahmetest. Muster:
1. Aktuelle Netzkonfiguration sichern.
2. Systemd-Timer auf 5 Minuten, der sie zurückspielt und neu startet.
3. Brücke aktivieren.
4. **Von außen** prüfen, ob der Host antwortet.
5. Erst dann den Timer abbestellen.
Der Schritt muss **erkennen**, was er vorfindet — DHCP auf einer Schnittstelle
(Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes
(Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist,
nimmt den anderen vom Netz. `ConfigureProxmox` bricht heute bewusst mit einer
Anleitung ab; dieser Block ersetzt das.
### Block C — Traefik als Systemdienst
Neuer Schritt zwischen `SecureHostFirewall` und `CompleteHostOnboarding`:
- Binary installieren, systemd-Unit, statische Konfiguration mit `websecure`-Entrypoint
und `letsencrypt`-certResolver, **deren Namen zu dem passen müssen, was
`SshTraefikWriter::render()` schon ausgibt** — hier zuerst nachlesen, nicht raten.
- Dynamisches Verzeichnis anlegen (`TRAEFIK_DYNAMIC_PATH`, Vorgabe
`/etc/traefik/dynamic`), Dateiprovider darauf zeigen.
- **Prüfen, nicht nur installieren:** Dienst läuft, 80/443 belegt, Ping-Endpunkt
antwortet. Ein Host darf nicht „aktiv" werden, wenn das Routing nicht steht.
- ACME braucht Port 80 **erreichbar** — die Firewall öffnet ihn, aber Traefik muss
lauschen, bevor das erste Zertifikat angefordert wird.
### Block D — Kleinigkeiten und eine kaufmännische Entscheidung
- **Rollen-Konvergenz auf bereits aktiven Hosts.** `Sys.Modify` erreicht sie nicht:
ein abgeschlossenes Onboarding kann nicht wiederholt werden, ein Kommando fehlt.
- **Traffic-Pakete (Ihre Entscheidung):** Der Auftrag ist einmalig, das Feld
`traffic_addons` ist ein *dauerhafter* Zähler. Hochzählen = 1000 GB für immer aus
einer Zahlung; nichts tun = der Kunde zahlte für nichts. Der Code protokolliert
das derzeit laut, statt still eines von beiden zu tun.
- `pveum acl modify … || true` und `pveum user add … || true` verdecken weiter
Fehler — dieselbe Klasse wie der behobene `role add`.
- `VerifyVmTemplate` prüft nur **Existenz**, nicht `template: 1`.
- `applyFirewall()` setzt **nicht** `firewall=1` an `net0` der VM — ohne das greifen
die Gastregeln trotz Datacenter-Firewall nicht. Prüfen, ob die Vorlage es mitbringt.
- ~~`stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon):
zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.~~
**Erledigt** — eindeutig seit `2026_07_31_210000_one_row_per_stripe_price`; zugleich
das Netz unter dem Wiedererkennungsschritt, siehe
`docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md`.
- `clupilot:end-due-services` schließt den **Vertrag** nicht — ein geschenkter Vertrag
bleibt nach Ende der Instanz für immer `active` und zählt in Umsatz/Dashboard mit.
- Eine Abbuchung, die **vor** dem Widerruf entstand und **danach** bezahlt wird,
bekommt einen Beleg, wird aber nicht storniert oder erstattet — ohne Signal.
- `Livewire\Admin\Provisioning::isStale()` erbt das neue 14-Tage-Budget: ein geparkter
Lauf mit totem Worker wird zwei Wochen nicht als hängend gemeldet.
---
## 4. Was gesetzt sein muss, bevor ein echter Lauf Sinn hat
Auf **diesem** Entwicklungsserver ist alles vorhanden; auf dem Live-Server ist es
ungeprüft. Vor dem Durchlauf einzeln bestätigen — jedes fehlende Stück bricht die
Kette an einer anderen Stelle, und mehrere brechen sie **nach** der Zahlung.
**Geheimnisse (Vault, Konsole → Integrationen):**
- `ssh.private_key` — ohne: `EstablishSshTrust` scheitert mit einem phpseclib-Parserfehler
- `dns.token` — Hetzner-DNS-Token. Ohne: der A-Record der VM entsteht nicht, der Kunde
hat keine Adresse. **Braucht Schreibrecht auf die Zone.**
- `stripe.secret` und `STRIPE_WEBHOOK_SECRET` — ohne: kein Auftrag entsteht überhaupt
- `monitoring.token` — Uptime-Kuma-Brücke, `RegisterMonitoring`
- `SECRETS_KEY`**seit heute Nacht auch für `hosts.api_token_ref`.** Fehlt er, ist
jeder Host-Token unlesbar und keine Bereitstellung läuft.
- `VPN_CONFIG_KEY`
**Einstellungen:**
- `CLUPILOT_DNS_ZONE` = **`clupilot.cloud`** (nicht `.com`). Vorgabe im Code ist
richtig; auf diesem Server stand in der `.env` versehentlich `.com`.
- `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_ENDPOINT`, `CLUPILOT_WG_SUBNET` — und der
**Hub muss laufen und auf 51820/udp erreichbar sein**. Die Hosts sind Initiatoren;
der offene UDP-Port ist eine Anforderung an den Hub, nicht an die Hosts.
- `CLUPILOT_DNS_HOSTS_DIR` — dnsmasq-Verzeichnis für die internen Hostnamen,
geteiltes Volume mit dem `vpn-dns`-Container
- `TRAEFIK_DYNAMIC_PATH`
- **Firmendaten vollständig** (Name, Adresse, PLZ, Ort, UID) **und Steuersatz**
sonst verweigert `IssueInvoice`, und der Kunde bekommt keinen Beleg
- **Mindestens ein Rechenzentrum** angelegt — `ValidateHostInput` verlangt es
- **Postfächer** eingerichtet — sonst erreicht die Zugangsdaten-Mail den Kunden nicht
**Betrieb:**
- `queue`, `queue-provisioning`, `scheduler` und `reverb` laufen — die Pipeline ist
warteschlangengetrieben, ohne Worker passiert schlicht nichts
- Stripe-Katalog nach dem Update: `stripe:sync-catalogue` **und**
`stripe:reprice-subscriptions` (je zuerst `--dry-run`). Der Abgleich legt jetzt
**zwei** Preise je Paket an; bis er lief, kann ein geprüfter EU-Firmenkunde nicht
bestellen — das ist Absicht.
---
## 5. Reihenfolge des ersten echten Durchlaufs
1. Testpaket „Test" in der Konsole anlegen (20 GB / 1 GB / 1 Kern / 5 GB) und veröffentlichen.
2. Abschnitt 4 durchgehen und **jedes** Stück bestätigen.
3. Debian 13 auf dem dedizierten Server, Host in der Konsole anlegen, onboarden.
Erwartung nach Block B/C: läuft bis `active` durch. Ohne Block B bricht es bei
`vmbr0` ab — das ist derzeit gewollt.
4. Vorlage bauen (Block A), `VerifyVmTemplate` muss sie finden.
5. Testpaket kaufen. Erwartung: 16 Schritte bis `CompleteProvisioning`, Nextcloud
erreichbar unter `<subdomain>.clupilot.cloud` mit gültigem Zertifikat,
Speicherkontingent greift, Zugangsdaten-Mail kommt an.
6. Eigene Domain hinterlegen, TXT-Nachweis, prüfen, dass sie **wirklich ausgeliefert**
wird (nicht nur „nachgewiesen").
7. Kündigen. Prüfen, dass Stripe es erfährt, die Leistung zum Periodenende endet,
Route und DNS-Eintrag verschwinden.
8. Zweiten Kauf als Privatperson, innerhalb 14 Tagen widerrufen. Prüfen: volle
Erstattung, Stornorechnung, Leistung endet.
Jeder Schritt, der scheitert, ist ein Fund — und der Server darf dabei kaputtgehen,
Debian ist neu eingespielt in Minuten.
---
## 6. Arbeitsweise in dieser Codebasis (für die neue Session)
- **Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben
Git-Index.** In dieser Nacht hat ein einziges `git commit` ohne Pfadangabe deren
vorgemerkte Dateien mitgenommen und dabei `VERSION` sowie zwei ihrer Dateien
zurückgedreht. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`,
**nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
- `pint` nur auf eigene Pfade, **nie** `--dirty` — das Repo ist unter der
Standardvorgabe nicht durchgängig formatiert und würde fremde Dateien umschreiben.
- Regeln R11R24 in `CLAUDE.md` sind nicht verhandelbar und werden per Test erzwungen.
R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
- Tests laufen auf **SQLite im Speicher** (`phpunit.xml` erzwingt es), gleichzeitige
Läufe kollidieren also nicht, und die Entwicklungsdatenbank wird nie angefasst.
- Bricht die ganze Suite auf einmal zusammen, ist das fast immer eine Datei der
anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man sucht.
- Push: `git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main`
mit `GIT_ACCESS_TOKEN` aus der `.env`; ein einfaches `git push` scheitert.
- Ein Update auf dem Live-Server greift nur bei einem **`v*`-Tag** — `VERSION` anheben,
taggen, Tag mitpushen.