CluPilotCloud/docs/superpowers/specs/2026-07-30-betriebsmodus-un...

17 KiB
Raw Blame History

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.