Design a test/live switch and a page that says what is missing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 10:11:26 +02:00
parent 36cb684d8b
commit 3768433792
1 changed files with 382 additions and 0 deletions

View File

@ -0,0 +1,382 @@
# Spec — Betriebsmodus (Test/Live) und Betriebsbereitschaft
**Datum:** 2026-07-30
**Status:** entworfen, noch nicht umgesetzt
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §4
---
## 1. Ziel
Zwei Dinge, die zusammengehören:
**A — Betriebsmodus.** Ein Schalter in der Konsole, `test` oder `live`. Jedes
Zugangsdatum im Tresor bekommt zwei Plätze; der aktive Modus entscheidet,
welcher gilt. Ist der Testplatz leer, gilt der Live-Platz — mit **einer**
Ausnahme, siehe §4.
**B — Betriebsbereitschaft.** Eine Seite, die jedes Pflichtfeld dieser
Installation aufführt, seinen Zustand zeigt und sagt, **was kaputtgeht, wenn
es fehlt**. Der Zweck ist genau einer: den Liveserver vollständig befüllen zu
können, bevor der erste Host onboardet wird — statt es nach der Zahlung eines
Kunden zu erfahren.
### Nicht Teil dieses Vorhabens
- **Testdaten-Trennung** (eigene Belegserie, Markierung, Ausschluss aus dem
Umsatz, Aufräumen inkl. VM und DNS). Eigener Entwurf, hängt an diesem hier.
Beschlossen, aber getrennt — dort wird auf einem Host eine VM zerstört, und
das braucht eigene Sorgfalt. Siehe §9.
- Zwei Plätze für die **einfachen Einstellungen** (DNS-Zone, WireGuard-Endpunkt,
Traefik-Pfad). Das sind keine Zugangsdaten, und eine getrennte Testzone wurde
bewusst nicht gewählt.
- Blöcke AD aus dem Handoff (goldene Vorlage, `vmbr0`, Traefik als Systemdienst).
---
## 2. Ausgangsbefund — gemessen, nicht gelesen
Alles hier am 2026-07-30 auf dem Entwicklungsserver nachgemessen.
| Befund | Beleg |
|---|---|
| Der Tresor hat **einen** Platz je Zugangsdatum | `SecretVault::REGISTRY`, fünf Einträge, je ein `config`-Schlüssel |
| Der hinterlegte Stripe-Schlüssel ist ein **Testschlüssel** | Präfix `sk_test_` |
| `STRIPE_WEBHOOK_SECRET` liegt bewusst **nicht** im Tresor | `SecretVault` Kopfkommentar; `StripeWebhookController:23` liest `config()` |
| Es gibt **keine** Bereitschaftsprüfung | Einziges Vorbild: `CompanyProfile::missingForInvoicing()`, benutzt in `Finance` und `NewInvoice` |
| `mail.default` steht auf `log` | Zugangsdaten-Mail landet in der Datei, nicht beim Kunden |
| Zwei Hosts stehen `active` **ohne** `api_token_ref` | `public_ip` `203.0.113.11`/`.21` — RFC 5737, Dokumentationsbereich |
| `CLUPILOT_WG_ENDPOINT` war eine private Adresse | `10.10.90.185:51820` |
| Kein Worker meldet, dass er lebt | Weder Zeitplaner noch `queue-provisioning` hinterlassen eine Spur |
Die letzten vier sind der Grund für diese Spec. Jeder davon ist in der Konsole
heute **unsichtbar**, und jeder bricht die Kette an einer anderen Stelle —
zwei davon erst nach der Zahlung.
---
## 3. Der Schalter
**Speicherort:** `App\Support\Settings`, Schlüssel `platform.mode`, Werte `test`
oder `live`.
Nicht die `.env`. `Settings` ist zwischengespeichert und verwirft den
Zwischenspeicher beim Schreiben — ein Umlegen wirkt sofort, auch in den
Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable bräuchte
einen Containerneustart; genau diese Falle hat am 2026-07-30 die korrigierte
DNS-Zone acht Stunden lang wirkungslos gelassen.
**Gelesen über** `App\Support\OperatingMode::current(): Mode`, damit nirgends
sonst im Code ein String verglichen wird. `Mode` ist ein Enum mit `Test` und
`Live`.
**Vorgabe:** `live`. Eine Installation, die nichts gespeichert hat, soll nicht
in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
**Wer darf umlegen:** `secrets.manage` (Inhaber) **und** bestätigtes Passwort —
dieselbe Sperre wie der Tresor, weil der Wechsel auf Live der Moment ist, ab dem
echtes Geld fließt. Bestätigung im Modal nach **R23**, nie `wire:confirm`.
**Wo:** Integrationsseite, Reiter `services`, **über** allen Zugangsdaten — er
regiert sie. Zusätzlich eine Plakette im Kopf der Konsole (`layouts.admin`),
solange der Modus `test` ist. „Bin ich im Test?" darf keine Frage sein, die man
durch Navigieren beantwortet.
---
## 4. Zwei Plätze je Zugangsdatum
Die kuratierte Liste bleibt, wie sie ist — der Tresor bleibt ein Tresor und kein
Schlüssel/Wert-Speicher. Nur die **Zeile** wird zweigeteilt:
```
stripe.secret:test stripe.secret:live
dns.token:test dns.token:live
monitoring.token:test monitoring.token:live
inbound_mail.password:test inbound_mail.password:live
ssh.private_key:test ssh.private_key:live
```
### Auflösungsregel
`SecretVault::get($key)` löst in dieser Reihenfolge auf:
1. Zeile des **aktiven Modus**
2. leer **und** der Eintrag erlaubt Rückfall → Zeile `live`
3. immer noch leer → `config()`, also `.env` — wie heute
Schritt 2 ist die Regel des Inhabers: für DNS, Monitoring, Postfach und SSH gibt
es nur ein Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine
Attrappe.
Schritt 3 bleibt **einwertig und modusfrei**. Die `.env` ist der Weg für eine
Installation, in der noch gar nichts gespeichert ist; ein zweiter Satz
Variablen dort würde den Anfangszustand verdoppeln, den es nur einmal gibt.
### Stripe ist ausgenommen — in beide Richtungen
Ein Merkmal `'strict' => true` in `REGISTRY`, das **nur** `stripe.secret` trägt.
Bedeutung: kein Rückfall, weder von Test auf Live noch umgekehrt. Fehlt der
Schlüssel des aktiven Modus, liefert `get()` `null`.
Ohne diese Ausnahme führte die Rückfallregel dazu, dass ein Testkauf bei
fehlendem Testschlüssel still den **Live-Schlüssel** benutzt und **echtes Geld**
abbucht, während die Konsole „Testbetrieb aktiv" anzeigt. Das ist genau die
Klasse von stillem Fehler, gegen die der ganze erste Durchlauf gebaut ist.
**Folge für die Aufrufer:** `HttpStripeClient::secret()` wirft eine sprechende
Ausnahme statt einen Aufruf ohne Token abzusetzen. Die Kasse fragt vorher die
Bereitschaftsprüfung und weist die Bestellung mit lesbarem Text ab (§6).
### Der Webhook-Schlüssel
Bleibt in der `.env`. Seine Begründung gilt unverändert: er wird bei **jedem**
eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die
Signaturprüfung still fehlschlagen lassen.
Zwei Variablen, ausgewählt nach Modus:
| Modus | Variable |
|---|---|
| `live` | `STRIPE_WEBHOOK_SECRET` |
| `test` | `STRIPE_WEBHOOK_SECRET_TEST` |
**Der Sonderfall ist bedacht.** Die Auswahl liest den Modus aus der Datenbank —
also aus genau der Quelle, die hier eigentlich vermieden werden sollte. Ist sie
nicht erreichbar, liefert `Settings::get()` seinen Vorgabewert `live` und
schreibt eine Warnung. Die Auswahl fällt damit auf den **Live**-Schlüssel, und
das ist die sichere Richtung: ein echtes Zahlungsereignis wird weiter korrekt
geprüft, ein Testereignis scheitert laut. Der umgekehrte Vorgabewert wäre der
gefährliche.
### Migration — sie rät nicht
- Jede bestehende Tresor-Zeile wandert auf `:live`.
- **Ausnahme `stripe.secret`:** einsortiert am Präfix. `sk_test_` oder
`rk_test_``:test`, alles andere → `:live`.
- **Der Anfangsmodus folgt daraus**, statt aus der Vorgabe: landet der
vorhandene Stripe-Schlüssel im Testplatz, war die Installation erkennbar im
Testbetrieb, und `platform.mode` wird auf `test` gesetzt. Ist gar kein
Stripe-Schlüssel da, bleibt es bei `live`.
Auf dem Entwicklungsserver heißt das: der laufende Aufbau bleibt heil, ohne dass
jemand von Hand nachsortiert.
---
## 5. Betriebsbereitschaft
`App\Support\Readiness` — nach dem Muster von `CompanyProfile::missingForInvoicing()`,
nur über alle Bereiche und mit mehr als einem Feldnamen je Eintrag.
Jede Prüfung liefert: Schlüssel, Beschriftung, **Gruppe** (was sie blockiert),
Schweregrad (`blocking` oder `warning`), einen Satz dazu, was kaputtgeht, und
den Reiter, der sie behebt.
Gruppiert nach dem, was sie blockiert — **nicht** danach, wo der Wert
gespeichert ist. Ein Betreiber, der eine Installation befüllt, denkt in „was
kann ich noch nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
### 5.1 Abrechnung
| Prüfung | Grad | Bricht |
|---|---|---|
| Stripe-Schlüssel des aktiven Modus | blocking | Kein Auftrag entsteht |
| Webhook-Schlüssel des aktiven Modus | blocking | Zahlung wird nie verbucht |
| Firmendaten (`missingForInvoicing()` aufrufen, nicht verdoppeln) | blocking | `IssueInvoice` verweigert, Kunde bekommt keinen Beleg |
| Rechnungsserie je Belegart vorhanden | blocking | Beleg kann keine Nummer ziehen |
| Jede veröffentlichte Paketversion hat beide Stripe-Preise | blocking | Geprüfter EU-Firmenkunde kann nicht bestellen |
Der **Steuersatz** ist bewusst keine Prüfung: `CompanyProfile::taxRate()` hat
einen `.env`-Rückfall und ist nie leer. Die **Einrichtungsgebühr** ebenso nicht:
`0` ist ein gültiger Zustand, bei dem der Satz von der Preisseite verschwindet.
### 5.2 Host-Onboarding
| Prüfung | Grad | Bricht |
|---|---|---|
| `ssh.private_key` | blocking | `EstablishSshTrust`, phpseclib-Parserfehler |
| `SECRETS_KEY` | blocking | Jeder Proxmox-Token unlesbar, keine Bereitstellung |
| `VPN_CONFIG_KEY` | blocking | WireGuard-Konfiguration nicht lesbar |
| `CLUPILOT_WG_HUB_PUBKEY`, `_ENDPOINT`, `_SUBNET` | blocking | Onboarding scheitert am Handshake |
| Mindestens ein Rechenzentrum | blocking | `ValidateHostInput` kommt nicht durch |
### 5.3 Bereitstellung
| Prüfung | Grad | Bricht |
|---|---|---|
| `dns.token` | blocking | A-Record entsteht nicht, Kunde hat keine Adresse |
| DNS-Zone gesetzt | blocking | Name wird in der falschen Zone gesucht |
| Traefik-Pfad, dnsmasq-Verzeichnis | warning | Vorgabe greift, aber ungeprüft |
| **Mindestens ein aktiver Host mit lesbarem `api_token_ref`** | blocking | Auftrag reserviert einen Host, den es nicht gibt — **nach** der Zahlung |
| Jede veröffentlichte Version zeigt auf eine Vorlage, die auf mindestens einem aktiven Host existiert | blocking | `CloneVirtualMachine` stirbt nach der Zahlung |
| `monitoring.token` | warning | Überwachung fehlt, Bereitstellung läuft |
Die vierte Zeile fängt genau die zwei Phantom-Hosts aus §2: `active`, aber ohne
Token. Sie sind heute in der Konsole von einem echten Host nicht zu
unterscheiden.
### 5.4 Zustellung
| Prüfung | Grad | Bricht |
|---|---|---|
| Mailversand geht **nicht** an `log` | blocking | VM läuft, Kunde erfährt nie davon |
| Mindestens ein Postfach | blocking | Kein Absender |
| Mail-Vorlagen vorhanden | warning | Fallback-Text statt gestalteter Mail |
| `inbound_mail.password` | warning | Nur eingehende Post betroffen |
### 5.5 Betrieb — Herzschläge
Zwei Prüfungen, die es heute nirgends gibt und die neu entstehen müssen:
- **Zeitplaner:** eine Aufgabe je Minute schreibt `Settings`-Schlüssel
`heartbeat.scheduler` mit Zeitstempel.
- **Bereitstellungs-Worker:** der Zeitplaner stellt einen winzigen Auftrag in
die `provisioning`-Warteschlange, der `Settings`-Schlüssel
`heartbeat.queue_provisioning` schreibt. Das beweist den **Worker**, nicht nur
den Zeitplaner — die beiden fallen getrennt aus.
Beide in `Settings`, nicht im Zwischenspeicher: ein Herzschlag, der mit dem
Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab.
Die Seite meldet einen Herzschlag als veraltet, wenn er älter als fünf Minuten
ist. Damit wird „ohne Worker passiert schlicht nichts, ohne Fehlermeldung"
sichtbar.
### 5.6 Prüfungen mit Knopf
Drei Einträge sind keine Feldabfragen, sondern echte Prüfungen — nach dem
Vorbild von `StripeCheck`, das schon existiert und in `REGISTRY` als `check`
eingetragen ist:
- **`DnsTokenCheck`** — fragt die Hetzner-API, ob der Token die konfigurierte
Zone sieht **und beschreiben darf**. Ein Leserecht-Token sieht in der Konsole
identisch aus wie ein Schreibrecht-Token; das ist der Unterschied zwischen
„gesetzt" und „funktioniert".
- **`WireguardEndpointCheck`** — ein UDP-Port lässt sich von außen nicht sauber
anklopfen, aber ob dort eine private Adresse (RFC 1918) oder eine
Dokumentationsadresse (RFC 5737) steht, ist entscheidbar. Fängt
`10.10.90.185:51820`.
- **`VmTemplateCheck`** — dieselbe Frage, die `VerifyVmTemplate` stellt, nur vor
dem Kauf statt danach.
### 5.7 Darstellung
Eigene Seite `App\Livewire\Admin\Readiness`, Route `/admin/readiness`, in der
Navigation. Auf der Übersicht ein Hinweis, sobald etwas Blockierendes offen ist.
Oben die eine Zeile, auf die es ankommt: **Bereit für Testbetrieb** bzw.
**Bereit für Livebetrieb** — welche Schlüssel Pflicht sind, hängt am Modus.
Jeder Eintrag verlinkt auf den Reiter, der ihn behebt. Icons nach **R18**
(`size-4` in Tabellen, einzeilig).
### 5.8 Die Seite ist kein Tor
Sie berichtet. Die harten Sperren bleiben dort, wo sie hingehören und heute
schon stehen:
- `IssueInvoice` verweigert bei unvollständigen Firmendaten
- `VerifyVmTemplate` verweigert einen Host ohne Vorlage
Dazu kommt **genau eine neue**: die Kasse nimmt keine Bestellung an, wenn dem
aktiven Modus der Stripe-Schlüssel fehlt.
Eine Bereitschaftsseite, die selbst sperrt, wäre eine zweite Wahrheit neben den
Prüfungen im Ablauf — und zwei Quellen für eine Frage sind der Weg, auf dem sie
auseinanderlaufen.
---
## 6. Betroffene Stellen
| Datei | Änderung |
|---|---|
| `app/Support/OperatingMode.php` | **neu** — Enum + `current()` |
| `app/Support/Readiness.php` | **neu** — die Prüfungsliste |
| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel |
| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft bei fehlendem Schlüssel des aktiven Modus |
| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus |
| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag |
| `app/Livewire/Admin/Readiness.php` | **neu** |
| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23 |
| `app/Livewire/Admin/Overview.php` | Hinweis bei blockierenden Lücken |
| `app/Services/Dns/DnsTokenCheck.php` | **neu** |
| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** |
| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** |
| `routes/`, `layouts.admin` | Route, Navigationseintrag, Modus-Plakette |
| Migration | Zeilenschlüssel umstellen, Stripe am Präfix einsortieren, Anfangsmodus setzen |
| Zeitplaner | zwei Herzschläge |
---
## 7. Tests
Nach dem Muster, das dieses Repo für seine Regeln benutzt (`IconLayoutTest`,
`ConfirmInModalTest`, `DisplayTimezoneTest`): die Regel wird **per Test
erzwungen**, nicht per Merksatz.
1. **Je Prüfung ein Test in beide Richtungen** — meldet fehlend, wenn es fehlt;
meldet erfüllt, wenn es da ist.
2. **Jeder `REGISTRY`-Eintrag muss auf der Bereitschaftsseite auftauchen.**
Damit kann ein künftig hinzugefügtes Zugangsdatum nicht still an der
Übersicht vorbeigehen.
3. **Rückfall Test → Live** greift für die nicht-strikten Einträge.
4. **Stripe fällt nicht zurück**, in beide Richtungen geprüft — der wichtigste
Test dieser Spec, weil sein Ausfall echtes Geld kostet.
5. **Die Kasse weist ab**, wenn dem aktiven Modus der Stripe-Schlüssel fehlt.
6. **Migration:** ein `sk_test_` landet im Testplatz und setzt den Modus auf
`test`; ein `sk_live_` landet im Live-Platz und lässt `live` stehen.
7. **Webhook-Auswahl:** bei nicht erreichbaren Einstellungen wird der
Live-Schlüssel benutzt, nicht der Test-Schlüssel.
8. **Kein `wire:confirm`** im neuen Modal (R23 gilt schon repoweit).
---
## 8. Reihenfolge
1. `OperatingMode` + Migration + `SecretVault`-Auflösung (Kern, ohne Oberfläche)
2. Stripe-Ausnahme + Kassensperre + Webhook-Auswahl
3. `Readiness` mit den reinen Feldprüfungen
4. Die drei Prüfungen mit Knopf
5. Herzschläge
6. Oberfläche: Umschalter, Bereitschaftsseite, Plakette, Übersichts-Hinweis
Schritt 12 ist der Teil, der falsch sein kann und Geld kostet. Schritt 36 ist
Fleißarbeit.
---
## 9. Risiken
- **Der Rückfall ist bequem und gefährlich.** Für vier von fünf Einträgen
richtig, für Stripe tödlich. Die `strict`-Ausnahme ist die einzige Zeile
dieser Spec, deren Ausfall echtes Geld kostet — sie gehört in den Test, nicht
in einen Kommentar.
- **Der Modus in der Datenbank** ist eine neue Abhängigkeit für die
Webhook-Prüfung, die bisher bewusst keine hatte. Entschärft durch den
Vorgabewert `live` bei Nichterreichbarkeit (§4), aber es bleibt eine
Abhängigkeit, die vorher nicht da war.
- **Die Bereitschaftsseite kann Vollständigkeit vortäuschen.** Sie prüft, was
jemand aufgeschrieben hat. Was niemand aufschreibt, bleibt unsichtbar — und
genau das war der Befund vom 2026-07-30: eine grüne Testsuite bei einer
Installation, die keinen Host durchinstallieren konnte. Deshalb Test 2, der
wenigstens für Zugangsdaten erzwingt, dass nichts vergessen wird.
---
## 10. Folgepunkte
- **Testdaten-Trennung** (Teil C, beschlossen): Markierung an Auftrag, Vertrag,
Beleg, Instanz und Kunde; eigene Rechnungsserie je Belegart für den
Testbetrieb, damit die lückenlose `RE`-Serie sauber bleibt und Testbelege
löschbar sind; Ausschluss aus Umsatz, Dashboard und Finanzansicht; ein
Aufräumen, das die ganze Kette entfernt — **einschließlich der VMs auf dem
Host und ihrer DNS-Einträge**. `EndInstanceService` nimmt Route und Eintrag
weg, rührt die VM aber ausdrücklich nicht an; `ProxmoxClient::deleteVm()`
existiert, der Aufrufer fehlt.
- Die zwei Phantom-Hosts (`pve-fsn-1`, `pve-hel-1`) aus dem Pool nehmen. Die
Bereitschaftsseite macht sie sichtbar, entfernt sie aber nicht.
- Idempotenz-Schlüssel im Stripe-Abgleich deckt die Metadaten nicht ab —
eigener Folgepunkt, am 2026-07-30 als Aufgabe abgelegt.