85 KiB
Betriebsmodus und Betriebsbereitschaft — Umsetzungsplan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ein Test/Live-Umschalter, der je Zugangsdatum zwei Plätze verwaltet, und eine Konsolenseite, die jedes fehlende Pflichtfeld benennt, bevor ein Kunde bezahlt.
Architecture: platform.mode liegt in Settings (sofort wirksam, auch in laufenden Warteschlangen-Prozessen) und wird ausschließlich über das Enum App\Support\OperatingMode gelesen. Der Tresor bleibt eine kuratierte Liste; nur seine Zeilenschlüssel werden zu {key}:{mode} zweigeteilt, und get() löst mit Rückfall auf Live auf — außer bei Stripe, das ein strict-Merkmal trägt und in beide Richtungen nicht zurückfällt. App\Support\Readiness sammelt alle Pflichtfeldprüfungen an einer Stelle und berichtet nur; die harten Sperren bleiben im Ablauf, wo sie heute schon stehen.
Tech Stack: PHP 8.3, Laravel 13.8, Livewire 3, Pest 4.7, MariaDB, Tailwind v3, Docker Compose.
Global Constraints
- Vollfassung des Entwurfs:
docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md. Bei Widerspruch gewinnt die Spec. - Commit-Disziplin, nicht verhandelbar. Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben Git-Index. Immer
git add -- <pfade>undgit commit -F - -- <pfade>. Niegit add -A,git add .,git commit -aoder ein nacktesgit commit. pintnur auf eigene Pfade, nie--dirty. Das Repo ist unter der Standardvorgabe nicht durchgängig formatiert.- R22: eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach wird ein offener Befund geparkt.
- R23: kein
wire:confirm, keinconfirm(in JavaScript. Bestätigung im Modal. - R24: jedes Modal mit einem Eingabefeld benutzt
<x-ui.modal>. - R18: Icons
size-4in Tabellen und Buttons,size-5in der Navigation, einzeilig. - R19: jede angezeigte Zeit geht durch
->local(). - Tests laufen im Container:
docker compose exec -T app php artisan test. SQLite im Speicher, erzwungen vonphpunit.xml— gleichzeitige Läufe kollidieren nicht. - Pest-Falle:
toThrow(SomeInterface::class)beweist nichts. Immer eine konkrete Klasse erwarten. - 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.
Dateistruktur
| Datei | Verantwortung |
|---|---|
app/Support/OperatingMode.php |
neu — Enum Test/Live und current(). Die einzige Stelle, die den String kennt. |
app/Services/Secrets/SecretVault.php |
Zeilenschlüssel je Modus, strict-Merkmal, Auflösungsregel. |
app/Services/Stripe/HttpStripeClient.php |
secret() wirft statt tokenlos zu senden. |
app/Exceptions/StripeNotConfigured.php |
neu — konkrete Ausnahme, damit Tests sie greifen können. |
app/Http/Controllers/StripeWebhookController.php |
Webhook-Schlüssel nach Modus. |
app/Livewire/Billing.php |
Kauf abweisen, wenn dem aktiven Modus der Stripe-Schlüssel fehlt. |
app/Support/Readiness.php |
neu — Sammler. Kennt keine einzelne Prüfung, nur die Liste. |
app/Support/Readiness/Check.php |
neu — ein Befund: Schlüssel, Gruppe, Grad, Text, Reiter. |
app/Support/Readiness/BillingChecks.php |
neu — Gruppe Abrechnung. |
app/Support/Readiness/OnboardingChecks.php |
neu — Gruppe Host-Onboarding. |
app/Support/Readiness/ProvisioningChecks.php |
neu — Gruppe Bereitstellung. |
app/Support/Readiness/DeliveryChecks.php |
neu — Gruppe Zustellung. |
app/Support/Readiness/OperationChecks.php |
neu — Gruppe Betrieb (Herzschläge). |
app/Services/Dns/DnsTokenCheck.php |
neu — Schreibrecht auf der Zone. |
app/Services/Vpn/WireguardEndpointCheck.php |
neu — Endpunkt ist öffentlich. |
app/Services/Proxmox/VmTemplateCheck.php |
neu — Vorlage liegt auf einem aktiven Host. |
app/Livewire/Admin/Readiness.php |
neu — die Seite. |
app/Livewire/Admin/ConfirmSwitchMode.php |
neu — Bestätigung nach R23. |
app/Livewire/Admin/Integrations.php |
Umschalter, zwei Felder je Eintrag. |
routes/console.php |
Zwei Herzschläge. |
Die Prüfgruppen sind fünf kleine Dateien statt einer großen: jede Gruppe hat eigene Abhängigkeiten (Stripe, Proxmox, DNS, Mail), und eine Datei, die alle vier importiert, wäre in jedem Test die ganze Welt.
Task 1: Der Betriebsmodus als Enum
Files:
- Create:
app/Support/OperatingMode.php - Test:
tests/Feature/OperatingModeTest.php
Interfaces:
-
Consumes:
App\Support\Settings(existiert). -
Produces:
OperatingMode::current(): OperatingMode, FälleOperatingMode::TestundOperatingMode::Live,->valueals'test'/'live',OperatingMode::set(OperatingMode $mode): void. -
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Support\OperatingMode;
use App\Support\Settings;
/**
* Der Modus entscheidet, welche Zugangsdaten gelten. Er wird an genau einer
* Stelle gelesen, damit nirgends sonst ein String verglichen wird — ein
* Tippfehler in einem Vergleich wäre sonst ein stiller Wechsel auf Live.
*/
it('is live when nothing has been stored', function () {
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('reads the stored mode', function () {
Settings::set('platform.mode', 'test');
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
it('falls back to live when the stored value is not a mode', function () {
// Eine Zeile, die von Hand oder von einer alten Fassung geschrieben wurde.
// Live ist die sichere Richtung: eine echte Zahlung wird korrekt behandelt,
// ein Testereignis scheitert laut.
Settings::set('platform.mode', 'staging');
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('writes the mode and takes effect immediately', function () {
OperatingMode::set(OperatingMode::Test);
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=OperatingModeTest
Expected: FAIL mit Class "App\Support\OperatingMode" not found
- Step 3: Das Enum schreiben
<?php
namespace App\Support;
/**
* Test oder Live — welcher Satz Zugangsdaten gilt.
*
* In `Settings` und nicht in der `.env`, weil `Settings` seinen Zwischenspeicher
* beim Schreiben verwirft: ein Umlegen wirkt sofort, auch in den
* Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable
* bräuchte einen Containerneustart — genau die Falle, die am 2026-07-30 eine
* korrigierte DNS-Zone acht Stunden lang wirkungslos gelassen hat.
*
* Live ist die Vorgabe. Eine Installation, die nichts gespeichert hat, soll
* nicht in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt.
*/
enum OperatingMode: string
{
case Test = 'test';
case Live = 'live';
public const SETTING = 'platform.mode';
public static function current(): self
{
// tryFrom, nicht from: ein unbekannter Wert in der Zeile ist ein
// Datenfehler und darf nicht jede Anfrage in eine Ausnahme werfen.
return self::tryFrom((string) Settings::get(self::SETTING, self::Live->value))
?? self::Live;
}
public static function set(self $mode): void
{
Settings::set(self::SETTING, $mode->value);
}
public function isTest(): bool
{
return $this === self::Test;
}
}
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=OperatingModeTest
Expected: PASS, 4 Tests
- Step 5: Committen
git add -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php
git commit -F - -- app/Support/OperatingMode.php tests/Feature/OperatingModeTest.php <<'EOF'
Give the installation a test mode and a live mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 2: Der Tresor bekommt zwei Plätze je Eintrag
Files:
- Modify:
app/Services/Secrets/SecretVault.php - Test:
tests/Feature/SecretVaultModeTest.php
Interfaces:
- Consumes:
OperatingMode::current()aus Task 1. - Produces:
SecretVault::get(string $key): ?string— Signatur unverändert, der Modus wird innen aufgelöst.put(string $key, string $value, Operator $by, ?OperatingMode $mode = null),forget(string $key, ?OperatingMode $mode = null),source(string $key, ?OperatingMode $mode = null),outline(string $key, ?OperatingMode $mode = null),updatedAt(string $key, ?OperatingMode $mode = null).nullheißt „aktiver Modus".
Wichtig: get() behält seine Signatur mit Absicht. Die Aufrufer (HttpStripeClient:367, StripeCheck:26, SshTraefikWriter:45) bleiben unverändert — nur die Konsole muss beide Plätze kennen.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
/**
* Zwei Plätze je Zugangsdatum, und eine Rückfallregel, die für vier von fünf
* Einträgen richtig ist: es gibt bei Hetzner, Uptime Kuma und SSH nur ein
* Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine Attrappe.
*/
beforeEach(function () {
$this->by = Operator::factory()->create();
$this->vault = app(SecretVault::class);
});
it('reads the slot of the active mode', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('test-token');
OperatingMode::set(OperatingMode::Live);
expect($this->vault->get('dns.token'))->toBe('live-token');
});
it('falls back to the live slot when the test slot is empty', function () {
// Die Regel des Inhabers: wo es keinen Testzugang gibt, wird der echte
// benutzt, damit der Testbetrieb überhaupt arbeiten kann.
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('live-token');
});
it('does not fall back from live to test', function () {
// Die andere Richtung wäre absurd: im Livebetrieb einen Testzugang zu
// benutzen, weil der echte fehlt.
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
expect($this->vault->get('dns.token'))->toBeNull();
});
it('still falls back to the environment when neither slot is filled', function () {
// Der Weg für eine Installation, in der noch gar nichts gespeichert ist.
// Einwertig und modusfrei — den Anfangszustand gibt es nur einmal.
config()->set('provisioning.dns.token', 'from-env');
expect($this->vault->get('dns.token'))->toBe('from-env');
});
it('reports the source per slot', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
});
it('forgets one slot without touching the other', function () {
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
$this->vault->put('dns.token', 'test-token', $this->by, OperatingMode::Test);
$this->vault->forget('dns.token', OperatingMode::Test);
expect($this->vault->source('dns.token', OperatingMode::Test))->toBe('none');
expect($this->vault->source('dns.token', OperatingMode::Live))->toBe('stored');
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=SecretVaultModeTest
Expected: FAIL — put() nimmt noch kein viertes Argument
- Step 3: Den Tresor umbauen
In app/Services/Secrets/SecretVault.php. Der Zeilenschlüssel wird zweigeteilt, alles andere bleibt:
/** Der Zeilenschlüssel im Speicher: ein Eintrag hat zwei Plätze. */
private function rowKey(string $key, ?OperatingMode $mode = null): string
{
return $key.':'.($mode ?? OperatingMode::current())->value;
}
private function row(string $key, ?OperatingMode $mode = null): ?object
{
return DB::table('app_secrets')->where('key', $this->rowKey($key, $mode))->first();
}
get() bekommt die Auflösungsregel:
public function get(string $key): ?string
{
$this->assertKnown($key);
$mode = OperatingMode::current();
$row = $this->row($key, $mode);
// Rückfall auf Live, wenn der Platz des aktiven Modus leer ist. NUR von
// Test auf Live, nie umgekehrt: im Livebetrieb einen Testzugang zu
// benutzen, weil der echte fehlt, wäre die gefährliche Richtung.
if ($row === null && $mode->isTest() && ! $this->isStrict($key)) {
$row = $this->row($key, OperatingMode::Live);
}
if ($row === null) {
// Strikte Einträge sehen die Umgebung nicht: siehe isStrict().
if ($this->isStrict($key)) {
return null;
}
$configured = config(self::REGISTRY[$key]['config']);
return $configured === null ? null : (string) $configured;
}
try {
return app(SecretCipher::class)->decrypt($row->value);
} catch (DecryptException $e) {
Log::error('A stored secret could not be decrypted', ['key' => $key]);
throw new RuntimeException("Stored secret [{$key}] cannot be decrypted.", previous: $e);
}
}
/** Trägt dieser Eintrag das strict-Merkmal? Gefüllt in Task 3. */
private function isStrict(string $key): bool
{
return (bool) (self::REGISTRY[$key]['strict'] ?? false);
}
put(), forget(), source(), outline(), updatedAt() bekommen je einen
?OperatingMode $mode = null-Parameter und reichen ihn an rowKey()/row()
durch. In put() wird DB::table('app_secrets')->updateOrInsert(['key' => $this->rowKey($key, $mode)], $attributes).
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=SecretVaultModeTest
Expected: PASS, 6 Tests
- Step 5: Die bestehende Tresor-Suite laufen lassen
Run: docker compose exec -T app php artisan test --filter=Secret
Expected: PASS — get() hat seine Signatur behalten, kein Aufrufer ändert sich
- Step 6: Committen
git add -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php
git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/SecretVaultModeTest.php <<'EOF'
Give every credential a test slot beside its live one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 3: Stripe fällt nicht zurück
Files:
- Modify:
app/Services/Secrets/SecretVault.php(Registry-Eintrag) - Test:
tests/Feature/StripeStrictModeTest.php
Interfaces:
- Consumes:
isStrict()aus Task 2. - Produces:
SecretVault::REGISTRY['stripe.secret']['strict'] === true.
Das ist die wichtigste Zeile dieses Plans. Ohne sie bucht ein Testkauf bei fehlendem Testschlüssel echtes Geld ab, während die Konsole „Testbetrieb" anzeigt.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
/**
* Stripe ist vom Rückfall ausgenommen — in BEIDE Richtungen.
*
* Fiele der Testbetrieb bei fehlendem Testschlüssel auf den Live-Schlüssel
* zurück, bucht ein Testkauf echtes Geld ab, während die Konsole „Testbetrieb
* aktiv" anzeigt. Das ist die eine Stelle in diesem Vorhaben, deren Ausfall
* Geld kostet, und deshalb steht sie als eigener Test da statt als Kommentar.
*/
beforeEach(function () {
$this->by = Operator::factory()->create();
$this->vault = app(SecretVault::class);
});
it('does not use the live key while the test mode is active', function () {
$this->vault->put('stripe.secret', 'sk_live_real', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('stripe.secret'))->toBeNull();
});
it('does not use the test key while the live mode is active', function () {
$this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Live);
expect($this->vault->get('stripe.secret'))->toBeNull();
});
it('ignores the environment for stripe as well', function () {
// Sonst wäre die .env die Hintertür, durch die der Live-Schlüssel doch in
// den Testbetrieb kommt.
config()->set('services.stripe.secret', 'sk_live_from_env');
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('stripe.secret'))->toBeNull();
});
it('uses the key of the active mode when it is there', function () {
$this->vault->put('stripe.secret', 'sk_test_fake', $this->by, OperatingMode::Test);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('stripe.secret'))->toBe('sk_test_fake');
});
it('lets every other entry keep falling back', function () {
// Die Ausnahme ist eine Ausnahme, keine neue Regel.
$this->vault->put('dns.token', 'live-token', $this->by, OperatingMode::Live);
OperatingMode::set(OperatingMode::Test);
expect($this->vault->get('dns.token'))->toBe('live-token');
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=StripeStrictModeTest
Expected: FAIL — die ersten drei Tests, weil noch zurückgefallen wird
- Step 3: Das Merkmal setzen
In SecretVault::REGISTRY, Eintrag stripe.secret:
'stripe.secret' => [
'config' => 'services.stripe.secret',
'label' => 'secrets.item.stripe_secret',
'check' => StripeCheck::class,
'env_key' => 'STRIPE_SECRET',
// Kein Rückfall, in keine Richtung, und auch nicht auf die Umgebung.
// Ohne das benutzt ein Testkauf bei fehlendem Testschlüssel still den
// Live-Schlüssel und bucht echtes Geld ab, während die Konsole
// „Testbetrieb aktiv" anzeigt. Jeder andere Eintrag hier bezeichnet
// dasselbe Konto in beiden Modi; dieser nicht.
'strict' => true,
],
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=StripeStrictModeTest
Expected: PASS, 5 Tests
- Step 5: Committen
git add -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php
git commit -F - -- app/Services/Secrets/SecretVault.php tests/Feature/StripeStrictModeTest.php <<'EOF'
Never spend real money while the switch says test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 4: Migration — bestehende Zeilen einsortieren, ohne zu raten
Files:
- Create:
database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php - Test:
tests/Feature/SecretSlotMigrationTest.php
Interfaces:
-
Consumes:
app_secrets.key,OperatingMode::SETTING. -
Produces: keine neue Schnittstelle. Nach der Migration trägt jede Zeile einen Modus im Schlüssel.
-
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
/**
* Die Migration rät nicht.
*
* Der Stripe-Schlüssel sagt selbst, wohin er gehört: `sk_test_` ist ein
* Testschlüssel, unabhängig davon, in welchem Feld er gerade liegt. Ihn stumpf
* als „Live" einzusortieren, würde eine Installation, die erkennbar im
* Testbetrieb läuft, beim ersten Kauf echtes Geld abbuchen lassen.
*/
function runSlotMigration(): void
{
(require base_path('database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php'))->up();
}
it('files a stripe test key in the test slot and switches the mode', function () {
DB::table('app_secrets')->insert([
'key' => 'stripe.secret',
'value' => encrypt('sk_test_abc'),
'created_at' => now(), 'updated_at' => now(),
]);
runSlotMigration();
expect(DB::table('app_secrets')->where('key', 'stripe.secret:test')->exists())->toBeTrue();
expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeFalse();
expect(Settings::get(OperatingMode::SETTING))->toBe('test');
});
it('files a stripe live key in the live slot and leaves the mode alone', function () {
DB::table('app_secrets')->insert([
'key' => 'stripe.secret',
'value' => encrypt('sk_live_abc'),
'created_at' => now(), 'updated_at' => now(),
]);
runSlotMigration();
expect(DB::table('app_secrets')->where('key', 'stripe.secret:live')->exists())->toBeTrue();
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('files every other credential in the live slot', function () {
DB::table('app_secrets')->insert([
'key' => 'dns.token',
'value' => encrypt('whatever'),
'created_at' => now(), 'updated_at' => now(),
]);
runSlotMigration();
expect(DB::table('app_secrets')->where('key', 'dns.token:live')->exists())->toBeTrue();
expect(DB::table('app_secrets')->where('key', 'dns.token')->exists())->toBeFalse();
});
it('leaves an installation with no stripe key on live', function () {
runSlotMigration();
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest
Expected: FAIL — die Migrationsdatei existiert nicht
- Step 3: Die Migration schreiben
Der entschlüsselte Wert wird gebraucht, um das Präfix zu lesen. Die Migration
benutzt denselben Chiffrierer wie der Tresor; scheitert die Entschlüsselung,
wandert die Zeile nach :live — das ist der Zustand vor dieser Migration und
damit die Fortsetzung des Status quo, nicht eine neue Behauptung.
<?php
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Jedes Zugangsdatum bekommt zwei Plätze — und die vorhandene Zeile wird
* einsortiert statt umbenannt.
*
* Für vier der fünf Einträge ist die Antwort dieselbe: was da liegt, wurde für
* den echten Betrieb hinterlegt, also `:live`. Nur Stripe kann es selbst sagen,
* weil sein Schlüssel sein Präfix trägt — und daran hängt mehr als eine Zeile:
* liegt dort ein Testschlüssel, war diese Installation erkennbar im
* Testbetrieb, und der Modus folgt daraus statt aus einer Vorgabe.
*/
return new class extends Migration
{
public function up(): void
{
$stripeMode = null;
foreach (DB::table('app_secrets')->get() as $row) {
// Schon einsortiert (etwa bei einem zweiten Lauf): unangetastet.
if (Str::contains($row->key, ':')) {
continue;
}
$mode = OperatingMode::Live;
if ($row->key === 'stripe.secret') {
$mode = $this->stripeModeOf($row->value);
$stripeMode = $mode;
}
DB::table('app_secrets')
->where('id', $row->id)
->update(['key' => $row->key.':'.$mode->value]);
}
// Nur wenn ein Stripe-Schlüssel da war. Ohne ihn bleibt die Vorgabe
// stehen, und die ist live.
if ($stripeMode !== null) {
Settings::set(OperatingMode::SETTING, $stripeMode->value);
}
}
private function stripeModeOf(string $stored): OperatingMode
{
try {
$secret = app(SecretCipher::class)->decrypt($stored);
} catch (\Throwable) {
// Unlesbar heißt: wir wissen es nicht. `:live` ist der Zustand vor
// dieser Migration, also die Fortsetzung des Status quo statt einer
// neuen Behauptung. Die Bereitschaftsseite meldet ihn ohnehin.
return OperatingMode::Live;
}
return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
}
public function down(): void
{
foreach (DB::table('app_secrets')->get() as $row) {
if (! Str::endsWith($row->key, [':live', ':test'])) {
continue;
}
$bare = Str::beforeLast($row->key, ':');
// Eine Zeile je Eintrag kann zurück; die zweite hat im alten Schema
// keinen Platz und würde am Eindeutigkeitsindex scheitern.
if (DB::table('app_secrets')->where('key', $bare)->exists()) {
DB::table('app_secrets')->where('id', $row->id)->delete();
continue;
}
DB::table('app_secrets')->where('id', $row->id)->update(['key' => $bare]);
}
Settings::forget(OperatingMode::SETTING);
}
};
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=SecretSlotMigrationTest
Expected: PASS, 4 Tests
- Step 5: Auf dem Entwicklungsserver laufen lassen und nachsehen
Nicht „grün heißt fertig". Die Migration fasst echte Zugangsdaten an.
docker compose exec -T app php artisan migrate --force
Dann prüfen, dass der vorhandene sk_test_-Schlüssel im Testplatz gelandet ist
und der Modus auf test steht:
docker compose exec -T app php artisan tinker --execute='
foreach(\Illuminate\Support\Facades\DB::table("app_secrets")->pluck("key") as $k) echo " $k\n";
echo "Modus: ".\App\Support\OperatingMode::current()->value."\n";'
Expected: stripe.secret:test, alle übrigen auf :live, Modus: test
- Step 6: Committen
git add -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php
git commit -F - -- database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php tests/Feature/SecretSlotMigrationTest.php <<'EOF'
Let the stored key say which mode it belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 5: Ohne Schlüssel wird nicht verkauft
Files:
- Create:
app/Exceptions/StripeNotConfigured.php - Modify:
app/Services/Stripe/HttpStripeClient.php:367 - Modify:
app/Livewire/Billing.php(purchase()bei Zeile 61) - Test:
tests/Feature/CheckoutWithoutStripeKeyTest.php
Interfaces:
-
Consumes:
SecretVault::get('stripe.secret')aus Task 3 (liefertnull). -
Produces:
App\Exceptions\StripeNotConfigured(konkrete Klasse — eine Schnittstelle imtoThrow()bewiese nichts). -
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Exceptions\StripeNotConfigured;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use App\Services\Stripe\HttpStripeClient;
use App\Support\OperatingMode;
use Livewire\Livewire;
/**
* Fehlt der Schlüssel des aktiven Modus, entsteht kein Auftrag — und der Kunde
* liest einen Satz statt einer 500er-Seite.
*
* Die Ausnahme ist eine KONKRETE Klasse. `toThrow(Throwable::class)` würde hier
* jeden beliebigen Fehler durchgehen lassen, auch einen Tippfehler im Test.
*/
it('refuses to reach stripe without a key for the active mode', function () {
OperatingMode::set(OperatingMode::Test);
expect(fn () => app(HttpStripeClient::class)->createPrice('prod_x', 100, 'EUR', 'month'))
->toThrow(StripeNotConfigured::class);
});
it('does not create an order when the key is missing', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
$user = User::factory()->for($customer)->create();
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'test')
->assertHasNoErrors();
// Der Punkt: nach der Zahlung wäre es zu spät. Es darf gar nichts entstehen.
expect(Order::count())->toBe(0);
});
it('says why instead of failing silently', function () {
OperatingMode::set(OperatingMode::Test);
$customer = Customer::factory()->create();
$user = User::factory()->for($customer)->create();
Livewire::actingAs($user)
->test(Billing::class)
->call('purchase', 'upgrade', 'test')
->assertSee(__('billing.stripe_not_configured'));
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest
Expected: FAIL mit Class "App\Exceptions\StripeNotConfigured" not found
- Step 3: Ausnahme und Sperre schreiben
app/Exceptions/StripeNotConfigured.php:
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
*
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
* übersetzen kann, den ein Kunde lesen kann.
*/
class StripeNotConfigured extends RuntimeException
{
public static function forMode(string $mode): self
{
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
}
}
In HttpStripeClient, die Methode bei Zeile 367:
private function secret(): string
{
$secret = app(SecretVault::class)->get('stripe.secret');
// Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
// und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
// zuordnet. Hier abzubrechen sagt, was fehlt.
if (blank($secret)) {
throw StripeNotConfigured::forMode(OperatingMode::current()->value);
}
return $secret;
}
In app/Livewire/Billing.php, ganz am Anfang von purchase() — vor
requireCustomer(), damit gar nichts angefasst wird:
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
{
// Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
// Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
// Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
if (blank(app(SecretVault::class)->get('stripe.secret'))) {
$this->addError('purchase', __('billing.stripe_not_configured'));
return;
}
$customer = $this->requireCustomer();
// ... unverändert weiter
In lang/de/billing.php (und lang/en/billing.php, falls vorhanden):
'stripe_not_configured' => 'Die Bezahlung ist derzeit nicht eingerichtet. Bitte versuchen Sie es später erneut.',
Der Kunde erfährt bewusst nicht, dass ein Schlüssel fehlt oder welcher Betriebsmodus läuft — das ist eine Betreiberangelegenheit und steht auf der Bereitschaftsseite.
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=CheckoutWithoutStripeKeyTest
Expected: PASS, 3 Tests
- Step 5: Die Kassen-Suite laufen lassen
Run: docker compose exec -T app php artisan test --filter=Billing
Expected: PASS — bestehende Tests hinterlegen einen Schlüssel und laufen weiter
- Step 6: Committen
git add -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php
git commit -F - -- app/Exceptions/StripeNotConfigured.php app/Services/Stripe/HttpStripeClient.php app/Livewire/Billing.php lang/de/billing.php tests/Feature/CheckoutWithoutStripeKeyTest.php <<'EOF'
Refuse the sale instead of reaching Stripe without a key
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 6: Der Webhook-Schlüssel folgt dem Modus
Files:
- Modify:
app/Http/Controllers/StripeWebhookController.php:23 - Modify:
config/services.php:39 - Modify:
.env.example - Test:
tests/Feature/StripeWebhookSecretByModeTest.php
Interfaces:
- Consumes:
OperatingMode::current(). - Produces:
config('services.stripe.webhook_secret')(live) undconfig('services.stripe.webhook_secret_test').
Der 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.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Support\OperatingMode;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Zwei Signaturschlüssel, weil Stripe je Modus einen eigenen vergibt.
*
* Der dritte Test ist der eigentliche Grund für diese Datei: die Auswahl liest
* den Modus aus der Datenbank — aus genau der Quelle, die für diesen Wert
* bewusst vermieden wurde. Fällt sie aus, muss die Auswahl auf LIVE fallen. Ein
* echtes Zahlungsereignis wird dann weiter korrekt geprüft; ein Testereignis
* scheitert laut. Die umgekehrte Vorgabe wäre die gefährliche.
*/
beforeEach(function () {
config()->set('services.stripe.webhook_secret', 'whsec_live');
config()->set('services.stripe.webhook_secret_test', 'whsec_test');
});
it('uses the live signing secret in live mode', function () {
OperatingMode::set(OperatingMode::Live);
expect(webhookSecret())->toBe('whsec_live');
});
it('uses the test signing secret in test mode', function () {
OperatingMode::set(OperatingMode::Test);
expect(webhookSecret())->toBe('whsec_test');
});
it('uses the live secret when the settings table cannot be read', function () {
OperatingMode::set(OperatingMode::Test);
// Der Ausfall, gegen den der Wert ursprünglich aus der Datenbank
// herausgehalten wurde.
Schema::drop('app_settings');
expect(webhookSecret())->toBe('whsec_live');
});
Die Hilfsfunktion webhookSecret() gehört als private static auf den
Controller und wird im Test über eine kleine Brücke gerufen — in Pest genügt:
function webhookSecret(): string
{
return (new ReflectionMethod(\App\Http\Controllers\StripeWebhookController::class, 'signingSecret'))
->invoke(null);
}
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest
Expected: FAIL — signingSecret() existiert nicht
- Step 3: Auswahl einbauen
config/services.php, im stripe-Block neben webhook_secret:
'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'),
.env.example, neben der bestehenden Zeile:
STRIPE_WEBHOOK_SECRET_TEST=
StripeWebhookController, Zeile 23 ersetzen:
$secret = self::signingSecret();
und die Methode dazu:
/**
* Der Signaturschlüssel des aktiven Betriebsmodus.
*
* Beide bleiben in der Serverdatei, nicht im Tresor: dieser Wert wird bei jedem
* eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die
* Signaturprüfung still fehlschlagen lassen — die eine Fehlerart, die laut
* bleiben muss.
*
* Die AUSWAHL fragt allerdings die Datenbank, was diese Abhängigkeit auf einem
* Umweg wieder einführt. Entschärft durch die Richtung des Ausfalls:
* `Settings::get()` liefert bei unerreichbarer Tabelle seinen Vorgabewert
* `live`, also wird der Live-Schlüssel benutzt. Ein echtes Zahlungsereignis
* wird weiter korrekt geprüft, ein Testereignis scheitert laut. Andersherum
* wäre es die gefährliche Richtung.
*/
private static function signingSecret(): string
{
return OperatingMode::current()->isTest()
? (string) config('services.stripe.webhook_secret_test')
: (string) config('services.stripe.webhook_secret');
}
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=StripeWebhookSecretByModeTest
Expected: PASS, 3 Tests
- Step 5: Committen
git add -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php
git commit -F - -- app/Http/Controllers/StripeWebhookController.php config/services.php .env.example tests/Feature/StripeWebhookSecretByModeTest.php <<'EOF'
Verify webhooks against the secret of the mode we are in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 7: Der Sammler und die Gruppe Abrechnung
Files:
- Create:
app/Support/Readiness/Check.php - Create:
app/Support/Readiness/BillingChecks.php - Create:
app/Support/Readiness.php - Test:
tests/Feature/Readiness/BillingChecksTest.php
Interfaces:
-
Consumes:
SecretVault,CompanyProfile::missingForInvoicing(),OperatingMode,InvoiceSeries,PlanPrice. -
Produces:
Check::__construct(string $key, string $group, string $severity, string $label, string $breaks, string $tab, bool $satisfied)Check::SEVERITY_BLOCKING = 'blocking',Check::SEVERITY_WARNING = 'warning'Readiness::all(): array<int, Check>Readiness::blocking(): array<int, Check>— nur unerfüllte mit Grad blockingReadiness::isReady(): boolBillingChecks::all(): array<int, Check>
-
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
/**
* Die Bereitschaftsprüfung berichtet, was fehlt — und sagt dazu, was
* kaputtgeht. Ein Feldname allein ist keine Auskunft: „vat_id fehlt" sagt
* nichts, „der Kunde bekommt keinen Beleg" sagt alles.
*/
function checkFor(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
it('reports the stripe key of the active mode as missing', function () {
OperatingMode::set(OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('reports it as satisfied once the key of that mode is stored', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});
it('does not accept a live key as proof while the test mode is active', function () {
// Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse
// sperrt — die Seite widerspräche der Sperre aus Task 5.
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
it('reports incomplete company details without repeating the list', function () {
Settings::forget('company.name');
expect(checkFor('billing.company_details')->satisfied)->toBeFalse();
});
it('names what breaks, not just what is missing', function () {
expect(checkFor('billing.company_details')->breaks)->not->toBe('');
});
it('says the installation is not ready while something blocking is open', function () {
expect(Readiness::isReady())->toBeFalse();
expect(Readiness::blocking())->not->toBeEmpty();
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=BillingChecksTest
Expected: FAIL mit Class "App\Support\Readiness" not found
- Step 3: Befund, Gruppe und Sammler schreiben
app/Support/Readiness/Check.php:
<?php
namespace App\Support\Readiness;
/**
* Ein Befund der Bereitschaftsprüfung.
*
* `breaks` ist das Feld, an dem diese Seite hängt. Eine Liste von Feldnamen ist
* keine Auskunft — sie sagt, was fehlt, nicht was daraus folgt. Am 2026-07-30
* war jedes fehlende Stück in der Konsole unsichtbar, und mehrere brachen die
* Kette erst NACH der Zahlung. Genau das gehört hier hinein.
*/
final class Check
{
public const SEVERITY_BLOCKING = 'blocking';
public const SEVERITY_WARNING = 'warning';
public function __construct(
public string $key,
public string $group,
public string $severity,
public string $label,
public string $breaks,
public string $tab,
public bool $satisfied,
) {}
public function isBlocking(): bool
{
return $this->severity === self::SEVERITY_BLOCKING && ! $this->satisfied;
}
}
app/Support/Readiness/BillingChecks.php:
<?php
namespace App\Support\Readiness;
use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\CompanyProfile;
use App\Support\OperatingMode;
/**
* Was gesetzt sein muss, damit überhaupt ein Auftrag entstehen und ein Beleg
* herauskommen kann.
*/
final class BillingChecks
{
public const GROUP = 'billing';
/** @return array<int, Check> */
public static function all(): array
{
$mode = OperatingMode::current();
return [
new Check(
key: 'billing.stripe_secret',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
breaks: __('readiness.billing.stripe_secret_breaks'),
tab: 'services',
// get() löst den aktiven Modus selbst auf UND ist für Stripe
// strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
satisfied: filled(app(SecretVault::class)->get('stripe.secret')),
),
new Check(
key: 'billing.webhook_secret',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.webhook_secret'),
breaks: __('readiness.billing.webhook_secret_breaks'),
tab: 'env',
satisfied: filled($mode->isTest()
? config('services.stripe.webhook_secret_test')
: config('services.stripe.webhook_secret')),
),
new Check(
key: 'billing.company_details',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.company_details'),
breaks: __('readiness.billing.company_details_breaks'),
tab: 'company',
// Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei
// Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen.
satisfied: CompanyProfile::missingForInvoicing() === [],
),
new Check(
key: 'billing.invoice_series',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.invoice_series'),
breaks: __('readiness.billing.invoice_series_breaks'),
tab: 'company',
satisfied: InvoiceSeries::query()
->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
->distinct()
->count('kind') === 3,
),
new Check(
key: 'billing.catalogue_synced',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.catalogue_synced'),
breaks: __('readiness.billing.catalogue_synced_breaks'),
tab: 'services',
satisfied: PlanPrice::query()->whereNull('stripe_price_id')->doesntExist(),
),
];
}
}
app/Support/Readiness.php:
<?php
namespace App\Support;
use App\Support\Readiness\BillingChecks;
use App\Support\Readiness\Check;
/**
* Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
* was es 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".
*
* Diese Klasse SPERRT NICHTS. Die harten Sperren bleiben, wo sie stehen —
* IssueInvoice bei unvollständigen Firmendaten, VerifyVmTemplate bei fehlender
* Vorlage, die Kasse bei fehlendem Stripe-Schlüssel. Eine Bereitschaftsseite,
* die selbst sperrt, wäre eine zweite Wahrheit neben diesen Prüfungen.
*/
final class Readiness
{
/** @return array<int, Check> */
public static function all(): array
{
return BillingChecks::all();
}
/** @return array<int, Check> */
public static function blocking(): array
{
return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking()));
}
public static function isReady(): bool
{
return self::blocking() === [];
}
/** @return array<string, array<int, Check>> */
public static function byGroup(): array
{
$grouped = [];
foreach (self::all() as $check) {
$grouped[$check->group][] = $check;
}
return $grouped;
}
}
lang/de/readiness.php anlegen mit den benutzten Schlüsseln, darunter:
'mode' => ['test' => 'Testbetrieb', 'live' => 'Livebetrieb'],
'billing' => [
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
'webhook_secret' => 'Stripe-Signaturschlüssel',
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
'company_details' => 'Firmendaten vollständig',
'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
'invoice_series' => 'Rechnungsserie je Belegart',
'invoice_series_breaks' => 'Ein Beleg kann keine Nummer ziehen.',
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
],
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=BillingChecksTest
Expected: PASS, 6 Tests
- Step 5: Committen
git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php
git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/BillingChecksTest.php <<'EOF'
Say what breaks, not just which field is empty
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 8: Die übrigen drei Gruppen
Files:
- Create:
app/Support/Readiness/OnboardingChecks.php - Create:
app/Support/Readiness/ProvisioningChecks.php - Create:
app/Support/Readiness/DeliveryChecks.php - Modify:
app/Support/Readiness.php(die drei Gruppen einhängen) - Modify:
lang/de/readiness.php - Test:
tests/Feature/Readiness/OnboardingChecksTest.php,tests/Feature/Readiness/ProvisioningChecksTest.php,tests/Feature/Readiness/DeliveryChecksTest.php
Interfaces:
- Consumes:
CheckundcheckFor()-Muster aus Task 7. - Produces:
OnboardingChecks::all(),ProvisioningChecks::all(),DeliveryChecks::all()— jearray<int, Check>.Readiness::all()fügt sie in der Reihenfolge Abrechnung, Onboarding, Bereitstellung, Zustellung zusammen.
Schlüssel der Prüfungen, vollständig — die Gruppe ist das Präfix:
| Schlüssel | Grad | Erfüllt, wenn |
|---|---|---|
onboarding.ssh_key |
blocking | SecretVault::get('ssh.private_key') gefüllt |
onboarding.secrets_key |
blocking | config('app.secrets_key') bzw. env('SECRETS_KEY') gefüllt |
onboarding.vpn_config_key |
blocking | env('VPN_CONFIG_KEY') gefüllt |
onboarding.wg_hub |
blocking | Hub-Pubkey, Endpunkt und Subnetz alle gefüllt |
onboarding.datacenter |
blocking | Datacenter::exists() |
provisioning.dns_token |
blocking | SecretVault::get('dns.token') gefüllt |
provisioning.dns_zone |
blocking | ProvisioningSettings::dnsZone() gefüllt |
provisioning.traefik_path |
warning | ProvisioningSettings::traefikDynamicPath() gefüllt |
provisioning.usable_host |
blocking | Host::where('status','active')->whereNotNull('api_token_ref')->exists() |
provisioning.vm_template |
blocking | Reine Feldprüfung: jede veröffentlichte Version hat eine template_vmid. Ob sie auf einem Knoten liegt, beantwortet erst der Knopf aus Task 9 — dafür muss Proxmox befragt werden, und das gehört nicht in jeden Seitenaufruf. |
provisioning.monitoring_token |
warning | SecretVault::get('monitoring.token') gefüllt |
delivery.mailer_not_log |
blocking | config('mail.default') !== 'log' |
delivery.mailbox |
blocking | Mailbox::exists() |
delivery.mail_templates |
warning | MailTemplate::exists() |
delivery.inbound_password |
warning | SecretVault::get('inbound_mail.password') gefüllt |
- Step 1: Die drei fehlschlagenden Testdateien schreiben
Beispiel für die Prüfung, die den Befund vom 2026-07-30 fängt —
tests/Feature/Readiness/ProvisioningChecksTest.php:
<?php
use App\Models\Host;
use App\Support\Readiness;
use App\Support\Readiness\Check;
function provisioningCheck(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* Am 2026-07-30 standen zwei Hosts auf `active`, ohne api_token_ref und mit
* Adressen aus dem Dokumentationsbereich (203.0.113.0/24, RFC 5737). In der
* Konsole waren sie von einem echten Host nicht zu unterscheiden — und
* ReserveResources wählt aus `status = active`. Ein Kauf hätte sich einen
* dieser Phantom-Hosts reserviert und wäre NACH der Zahlung gestorben.
*/
it('does not accept an active host without a readable token', function () {
Host::factory()->create(['status' => 'active', 'api_token_ref' => null]);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});
it('accepts an active host that carries a token', function () {
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1']);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeTrue();
});
it('does not accept a host that carries a token but is not active', function () {
Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1']);
expect(provisioningCheck('provisioning.usable_host')->satisfied)->toBeFalse();
});
it('treats the monitoring token as a warning, never as a blocker', function () {
// Überwachung darf eine Bereitstellung nie aufhalten.
expect(provisioningCheck('provisioning.monitoring_token')->severity)
->toBe(Check::SEVERITY_WARNING);
});
Und in tests/Feature/Readiness/DeliveryChecksTest.php der Fall, der heute
still danebengeht:
<?php
use App\Models\Mailbox;
use App\Support\Readiness;
function deliveryCheck(string $key): ?\App\Support\Readiness\Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* `mail.default = log` heißt: die VM läuft, der Beleg ist geschrieben, und die
* Zugangsdaten-Mail liegt in einer Datei auf dem Server. Der Kunde erfährt nie,
* dass er eine Cloud hat. Kein Fehler taucht irgendwo auf.
*/
it('refuses to call an installation ready while mail goes to the log file', function () {
config()->set('mail.default', 'log');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeFalse();
});
it('accepts a real mailer', function () {
config()->set('mail.default', 'smtp');
expect(deliveryCheck('delivery.mailer_not_log')->satisfied)->toBeTrue();
});
it('needs at least one mailbox to send from', function () {
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeFalse();
Mailbox::factory()->create();
expect(deliveryCheck('delivery.mailbox')->satisfied)->toBeTrue();
});
tests/Feature/Readiness/OnboardingChecksTest.php:
<?php
use App\Models\Datacenter;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Settings;
function onboardingCheck(string $key): ?\App\Support\Readiness\Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
it('needs at least one datacenter', function () {
// ValidateHostInput ist der erste Schritt der Host-Kette und bricht ohne
// Rechenzentrum ab — bevor überhaupt eine SSH-Verbindung versucht wird.
expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeFalse();
Datacenter::factory()->create();
expect(onboardingCheck('onboarding.datacenter')->satisfied)->toBeTrue();
});
it('needs an ssh private key', function () {
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeFalse();
app(SecretVault::class)->put(
'ssh.private_key',
'-----BEGIN OPENSSH PRIVATE KEY-----',
Operator::factory()->create(),
OperatingMode::Live,
);
expect(onboardingCheck('onboarding.ssh_key')->satisfied)->toBeTrue();
});
it('needs all three wireguard values, not just some', function () {
// Zwei von drei ist kein halber Tunnel, sondern gar keiner.
Settings::set('wg.hub_pubkey', 'abc=');
Settings::set('wg.endpoint', '198.51.45.9:51820');
expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeFalse();
Settings::set('wg.subnet', '10.66.0.0/24');
expect(onboardingCheck('onboarding.wg_hub')->satisfied)->toBeTrue();
});
Die Settings-Schlüssel für WireGuard bitte an ProvisioningSettings ablesen,
bevor die Prüfung geschrieben wird — dort steht, wie sie tatsächlich heißen.
- Step 2: Tests laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=ChecksTest
Expected: FAIL — die drei Gruppenklassen existieren nicht
- Step 3: Die drei Gruppen schreiben
Jede Klasse nach dem Vorbild von BillingChecks: eine public const GROUP, eine
public static function all(): array, ein Check je Zeile der Tabelle oben, mit
label und breaks aus lang/de/readiness.php. Beispiel für die entscheidende
Zeile in ProvisioningChecks:
new Check(
key: 'provisioning.usable_host',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.provisioning.usable_host'),
breaks: __('readiness.provisioning.usable_host_breaks'),
tab: 'hosts',
// `active` ALLEIN reicht nicht. Ein Host ohne lesbaren Token ist für
// ReserveResources trotzdem wählbar, und der Auftrag stirbt dann in
// CloneVirtualMachine — nach der Zahlung.
satisfied: Host::query()
->where('status', 'active')
->whereNotNull('api_token_ref')
->exists(),
),
Danach Readiness::all() erweitern:
public static function all(): array
{
return [
...BillingChecks::all(),
...OnboardingChecks::all(),
...ProvisioningChecks::all(),
...DeliveryChecks::all(),
];
}
- Step 4: Tests laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=ChecksTest
Expected: PASS
- Step 5: Gegen den echten Server halten
Nicht „grün heißt fertig" — die Prüfungen müssen auf dieser Installation das melden, was am 2026-07-30 gemessen wurde.
docker compose exec -T app php artisan tinker --execute='
foreach(\App\Support\Readiness::all() as $c)
printf(" %-34s %-8s %s\n", $c->key, $c->severity, $c->satisfied ? "OK" : "FEHLT");'
Expected: provisioning.usable_host FEHLT (die zwei Phantom-Hosts), delivery.mailer_not_log FEHLT (mail.default=log), delivery.inbound_password FEHLT, Firmendaten OK, Rechenzentrum OK.
- Step 6: Committen
git add -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/
git commit -F - -- app/Support/Readiness.php app/Support/Readiness/ lang/de/readiness.php tests/Feature/Readiness/
Nachricht:
Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 9: Die drei Prüfungen, die wirklich nachsehen
Files:
- Create:
app/Services/Dns/DnsTokenCheck.php - Create:
app/Services/Vpn/WireguardEndpointCheck.php - Create:
app/Services/Proxmox/VmTemplateCheck.php - Test:
tests/Feature/Readiness/ActiveChecksTest.php
Interfaces:
- Consumes:
HetznerDnsClient,ProvisioningSettings,ProxmoxClient::vmExists(),PlanVersion. - Produces: je eine
final classmitrun(): arrayin genau der Form, dieStripeCheckschon liefert —['ok' => bool, 'reason' => string], bei Bedarf weitere Schlüssel. Kein eigener Rückgabetyp daneben: die Konsole behandelt heute schon ein solches Array, und ein zweites Format wäre die zweite Wahrheit, die dieser Entwurf vermeidet.
Diese drei sind der Unterschied zwischen „gesetzt" und „funktioniert".
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Services\Vpn\WireguardEndpointCheck;
/**
* Ein UDP-Port lässt sich von außen nicht sauber anklopfen — es gibt keinen
* Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
* Adresse steht, die ein Host im Internet erreichen kann.
*
* Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
* gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
*/
it('rejects a private address', function () {
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
});
it('rejects a documentation address', function () {
// RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
// Installationen.
expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
});
it('rejects loopback', function () {
expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
});
it('accepts a public address', function () {
expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
});
it('accepts a hostname, which it cannot judge', function () {
// Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
// durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
});
it('rejects an endpoint without a port', function () {
expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
});
it('names the offending address, so the page can show it', function () {
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
});
Für DnsTokenCheck mit Http::fake(): ein Fake, der die Zone listet und
Schreibrecht bestätigt (ok), einer der die Zone nicht enthält (nicht ok),
und einer, der bei einem Schreibversuch 403 liefert (nicht ok — genau der
Leserecht-Token aus dem Handoff).
Für VmTemplateCheck mit einem gefälschten ProxmoxClient: Vorlage vorhanden
(ok), Vorlage fehlt (nicht ok, mit der VMID im Text), kein aktiver Host
(nicht ok).
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=ActiveChecksTest
Expected: FAIL — die Klassen existieren nicht
- Step 3: Die drei Prüfungen schreiben
WireguardEndpointCheck — kein Netzverkehr, nur eine Entscheidung über die
Adresse:
/** @return array<string, mixed> */
public function run(?string $endpoint = null): array
{
$endpoint ??= ProvisioningSettings::wgEndpoint();
if (blank($endpoint)) {
return ['ok' => false, 'reason' => 'missing'];
}
if (! str_contains($endpoint, ':')) {
return ['ok' => false, 'reason' => 'no_port'];
}
$host = Str::beforeLast($endpoint, ':');
// Keine gültige IP heißt: ein Name. Der kann öffentlich auflösen, und hier
// zu raten wäre schlechter als durchzulassen — die Prüfung sagt, was sie
// weiß, nicht was sie vermutet.
if (! filter_var($host, FILTER_VALIDATE_IP)) {
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
}
// NO_PRIV_RANGE deckt RFC 1918 ab, NO_RES_RANGE unter anderem Loopback und
// 203.0.113.0/24 — genau die Adressen, die am 2026-07-30 an zwei Hosts
// standen.
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
return $public === false
? ['ok' => false, 'reason' => 'not_public', 'address' => $host]
: ['ok' => true, 'reason' => 'public', 'address' => $host];
}
DnsTokenCheck — die Zone zu listen beweist nichts, weil ein
Leserecht-Token das anstandslos tut. Also wird geschrieben und sofort
zurückgenommen:
/** @return array<string, mixed> */
public function run(?string $candidate = null): array
{
$token = $candidate ?: app(SecretVault::class)->get('dns.token');
$zone = ProvisioningSettings::dnsZone();
if (blank($token) || blank($zone)) {
return ['ok' => false, 'reason' => 'missing'];
}
try {
$zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->get('https://dns.hetzner.com/api/v1/zones');
} catch (Throwable) {
return ['ok' => false, 'reason' => 'unreachable'];
}
if ($zones->status() === 401 || $zones->status() === 403) {
return ['ok' => false, 'reason' => 'rejected'];
}
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
if ($zoneId === null) {
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
}
// Der eigentliche Punkt. Ein Leserecht-Token kommt bis hierher und sieht in
// der Konsole aus wie ein funktionierender; er scheitert erst, wenn eine
// Kunden-VM ihren A-Record braucht — nach der Zahlung.
$probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->post('https://dns.hetzner.com/api/v1/records', [
'zone_id' => $zoneId,
'type' => 'TXT',
'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
'value' => 'clupilot readiness probe',
'ttl' => 60,
]);
if (! $probe->successful()) {
return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
}
// Immer aufräumen, auch wenn das Löschen scheitert: ein liegengebliebener
// TXT-Eintrag mit diesem Namen ist harmlos und wird im Ergebnis benannt,
// damit ihn jemand von Hand entfernen kann.
$recordId = $probe->json('record.id');
$removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
->successful();
return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
}
VmTemplateCheck stellt dieselbe Frage wie VerifyVmTemplate — jede
veröffentlichte Paketversion zeigt auf eine template_vmid, die auf einem Knoten
liegen muss —, nur über alle aktiven Hosts statt über einen, und vor dem
Kauf statt danach:
/** @return array<string, mixed> */
public function run(): array
{
$required = PlanVersion::query()
->whereNotNull('published_at')
->whereNotNull('template_vmid')
->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();
// Ein Katalog ohne veröffentlichte Version verlangt keine Vorlage. Derselbe
// Fall, den VerifyVmTemplate ausdrücklich durchlässt.
if ($required === []) {
return ['ok' => true, 'reason' => 'nothing_published'];
}
$hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();
if ($hosts->isEmpty()) {
return ['ok' => false, 'reason' => 'no_usable_host'];
}
$missing = [];
foreach ($required as $vmid) {
$found = false;
foreach ($hosts as $host) {
try {
if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
$found = true;
break;
}
} catch (Throwable) {
// Ein Host, der gerade nicht antwortet, ist kein Beweis für eine
// fehlende Vorlage. Weitersuchen.
continue;
}
}
if (! $found) {
$missing[] = $vmid;
}
}
return $missing === []
? ['ok' => true, 'reason' => 'present']
: ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
}
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=ActiveChecksTest
Expected: PASS
- Step 5: Gegen den echten Server halten
docker compose exec -T app php artisan tinker --execute='
$r = app(\App\Services\Vpn\WireguardEndpointCheck::class)->run();
echo "WG: ".($r["ok"] ? "OK" : "FEHLT")." — ".json_encode($r)."\n";
$d = app(\App\Services\Dns\DnsTokenCheck::class)->run();
echo "DNS: ".($d["ok"] ? "OK" : "FEHLT")." — ".json_encode($d)."\n";'
Expected: WG meldet die private Adresse. DNS sagt, ob der hinterlegte Token die
Zone clupilot.cloud wirklich beschreiben darf — die Frage, die seit dem
Handoff offen ist.
- Step 6: Committen
git add -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php
git commit -F - -- app/Services/Dns/DnsTokenCheck.php app/Services/Vpn/WireguardEndpointCheck.php app/Services/Proxmox/VmTemplateCheck.php lang/de/readiness.php tests/Feature/Readiness/ActiveChecksTest.php
Nachricht:
Tell a read-only token apart from one that can write
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 10: Herzschläge — beweisen, dass die Worker leben
Files:
- Create:
app/Provisioning/Jobs/RecordProvisioningHeartbeat.php - Create:
app/Support/Readiness/OperationChecks.php - Modify:
routes/console.php - Modify:
app/Support/Readiness.php - Test:
tests/Feature/Readiness/HeartbeatTest.php
Interfaces:
- Consumes:
Settings::set/get,OperationChecksfolgt dem Muster aus Task 7. - Produces:
Settings-Schlüsselheartbeat.schedulerundheartbeat.queue_provisioning, je ein ISO-8601-Zeitstempel. Prüfungenoperation.schedulerundoperation.queue_provisioning.
Beide in Settings, nicht im Zwischenspeicher: ein Herzschlag, der mit dem
Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Provisioning\Jobs\RecordProvisioningHeartbeat;
use App\Support\Readiness;
use App\Support\Settings;
function operationCheck(string $key): ?\App\Support\Readiness\Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
/**
* „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser
* Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden.
*
* Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker
* getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die
* niemand abholt.
*/
it('reports a missing heartbeat as not satisfied', function () {
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});
it('accepts a fresh heartbeat', function () {
Settings::set('heartbeat.scheduler', now()->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
});
it('rejects a heartbeat older than five minutes', function () {
Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse();
});
it('proves the worker, not just the scheduler', function () {
// Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine
// Herzschlag ist frisch, der andere nicht.
Settings::set('heartbeat.scheduler', now()->toIso8601String());
expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue();
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse();
});
it('records the worker heartbeat when the job runs', function () {
(new RecordProvisioningHeartbeat)->handle();
expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue();
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=HeartbeatTest
Expected: FAIL — Job und Gruppe existieren nicht
- Step 3: Job, Gruppe und Zeitplan schreiben
app/Provisioning/Jobs/RecordProvisioningHeartbeat.php:
<?php
namespace App\Provisioning\Jobs;
use App\Support\Settings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Ein Lebenszeichen des Bereitstellungs-Workers.
*
* Der Zeitplaner stellt ihn ein, der Worker führt ihn aus — also beweist er
* genau das, was der Zeitplaner-Herzschlag NICHT beweist: dass jemand die
* Warteschlange abholt. Die beiden fallen getrennt aus, und der Fall
* „Zeitplaner läuft, Worker steht" ist der stille: Aufträge sammeln sich, und
* nichts meldet etwas.
*/
class RecordProvisioningHeartbeat implements ShouldQueue
{
use Queueable;
public $queue = 'provisioning';
public function handle(): void
{
Settings::set('heartbeat.queue_provisioning', now()->toIso8601String());
}
}
In routes/console.php, bei den übrigen Zeitplänen:
// Zwei Lebenszeichen für die Bereitschaftsseite. In Settings und nicht im
// Zwischenspeicher: ein Herzschlag, der mit dem Redis-Neustart verschwindet,
// meldete einen Ausfall, den es nicht gab.
Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String()))
->everyMinute()
->name('heartbeat-scheduler');
Schedule::job(new RecordProvisioningHeartbeat)
->everyMinute()
->name('heartbeat-provisioning');
OperationChecks::all() liest beide Zeitstempel und vergleicht gegen
now()->subMinutes(5); ein fehlender Wert ist „nicht erfüllt".
Und Readiness::all() bekommt die fünfte Gruppe — sonst laufen die Prüfungen
ins Leere:
public static function all(): array
{
return [
...BillingChecks::all(),
...OnboardingChecks::all(),
...ProvisioningChecks::all(),
...DeliveryChecks::all(),
...OperationChecks::all(),
];
}
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=HeartbeatTest
Expected: PASS, 5 Tests
- Step 5: Am laufenden Server bestätigen
Zwei Minuten warten, dann:
docker compose exec -T app php artisan tinker --execute='
echo "Zeitplaner: ".\App\Support\Settings::get("heartbeat.scheduler", "—")."\n";
echo "Worker: ".\App\Support\Settings::get("heartbeat.queue_provisioning", "—")."\n";'
Expected: beide mit einem Zeitstempel der letzten Minuten. Bleibt der zweite
leer, während der erste läuft, ist das kein Testfehler — dann steht der
queue-provisioning-Worker, und die Prüfung hat beim ersten Versuch getan,
wofür sie gebaut wurde.
- Step 6: Committen
git add -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php
git commit -F - -- app/Provisioning/Jobs/RecordProvisioningHeartbeat.php app/Support/Readiness/OperationChecks.php app/Support/Readiness.php routes/console.php lang/de/readiness.php tests/Feature/Readiness/HeartbeatTest.php
Nachricht:
Notice when nobody is picking up the queue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 11: Der Umschalter in der Konsole
Files:
- Create:
app/Livewire/Admin/ConfirmSwitchMode.php - Create:
resources/views/livewire/admin/confirm-switch-mode.blade.php - Modify:
app/Livewire/Admin/Integrations.php - Modify:
resources/views/livewire/admin/integrations.blade.php - Modify:
resources/views/layouts/admin.blade.php(Plakette) - Test:
tests/Feature/SwitchOperatingModeTest.php
Interfaces:
- Consumes:
OperatingMode::set(),ConfirmsPassword(bestehendes Trait), das bestehendeopenModal-Muster. - Produces: Livewire-Ereignis
mode-switch-confirmedmit Argumentmode.
Muster wortgleich zu ConfirmSaveSecret: das Modal mutiert nichts selbst,
sondern löst ein Ereignis aus, das die Seitenkomponente per #[On(...)]
auffängt — so bleibt die Berechtigungsprüfung an der einen Stelle, an der sie
schon steht.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Livewire\Admin\Integrations;
use App\Models\Operator;
use App\Support\OperatingMode;
use Livewire\Livewire;
// Die Rollen heißen exakt so (Operator::OPERATOR_ROLES), und die Fabrik setzt
// sie über ->role(), nicht über eine ->owner()-Abkürzung. Das Testpasswort ist
// `passwort-fuer-tests` — OperatorFactory::definition().
it('refuses to switch without the secrets capability', function () {
$admin = Operator::factory()->role('Admin')->create(); // hosts.manage, nicht secrets.manage
Livewire::actingAs($admin)
->test(Integrations::class)
->call('switchMode', 'test')
->assertForbidden();
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
it('switches when the owner has confirmed their password', function () {
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner)
->test(Integrations::class)
->call('confirmPassword', 'passwort-fuer-tests')
->call('switchMode', 'test');
expect(OperatingMode::current())->toBe(OperatingMode::Test);
});
it('refuses an unknown mode', function () {
// Ein String aus dem Browser. `staging` gibt es nicht, und from() würde
// hier eine Ausnahme werfen statt still nichts zu tun.
$owner = Operator::factory()->role('Owner')->create();
Livewire::actingAs($owner)
->test(Integrations::class)
->call('confirmPassword', 'passwort-fuer-tests')
->call('switchMode', 'staging');
expect(OperatingMode::current())->toBe(OperatingMode::Live);
});
Dazu ein Blade-Wächtertest, der R23 für die neue Datei mitnimmt — er existiert
bereits als tests/Feature/ConfirmInModalTest.php und läuft über das ganze
Repo, also muss hier nichts ergänzt werden. Er wird in Step 4 mitgeprüft.
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest
Expected: FAIL — switchMode existiert nicht
- Step 3: Umschalter und Modal schreiben
In Integrations:
/**
* Den Betriebsmodus umlegen.
*
* Hinter derselben Sperre wie der Tresor — `secrets.manage` plus bestätigtes
* Passwort — weil der Wechsel auf Live der Moment ist, ab dem echtes Geld
* fließt. Das ist keine Umschaltfläche zum Danebenklicken.
*/
#[On('mode-switch-confirmed')]
public function switchMode(string $mode): void
{
$this->guardSecrets();
// Ein String aus dem Browser. tryFrom, nicht from.
$target = OperatingMode::tryFrom($mode);
if ($target === null) {
return;
}
OperatingMode::set($target);
$this->dispatch('notify', message: __('integrations.mode_switched', [
'mode' => __('readiness.mode.'.$target->value),
]));
}
Das Modal ConfirmSwitchMode bekommt den Zielmodus als Argument, zeigt einen
Satz, der beim Wechsel auf Live ausdrücklich sagt, dass ab dann echtes Geld
bewegt wird, und löst beim Bestätigen mode-switch-confirmed aus.
In layouts/admin.blade.php die Plakette, solange der Modus test ist —
Icon nach R18 size-4, einzeilig:
@if (\App\Support\OperatingMode::current()->isTest())
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
<x-ui.icon name="beaker" class="size-4" />
{{ __('readiness.mode.test') }}
</span>
@endif
- Step 4: Tests laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=SwitchOperatingModeTest
Expected: PASS, 3 Tests
Run: docker compose exec -T app php artisan test --filter="ConfirmInModal|IconLayout|ModalHeight"
Expected: PASS — die Regelwächter R23, R18 und R24 nehmen die neuen Dateien mit
- Step 5: Committen
git add -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php
git commit -F - -- app/Livewire/Admin/ConfirmSwitchMode.php app/Livewire/Admin/Integrations.php resources/views/livewire/admin/confirm-switch-mode.blade.php resources/views/livewire/admin/integrations.blade.php resources/views/layouts/admin.blade.php lang/de/integrations.php tests/Feature/SwitchOperatingModeTest.php
Nachricht:
Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 12: Die Bereitschaftsseite — und der Wächter, der sie vollständig hält
Files:
- Create:
app/Livewire/Admin/Readiness.php - Create:
resources/views/livewire/admin/readiness.blade.php - Modify:
routes/web.php(Route/admin/readiness) - Modify:
resources/views/layouts/admin.blade.php(Navigationseintrag) - Modify:
app/Livewire/Admin/Overview.phpund dessen Blade (Hinweis) - Test:
tests/Feature/ReadinessPageTest.php
Interfaces:
-
Consumes:
Readiness::byGroup(),Readiness::blocking(),Readiness::isReady(). -
Produces: benannte Route
admin.readiness. -
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Livewire\Admin\Readiness as ReadinessPage;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\Readiness;
use Livewire\Livewire;
it('is closed to operators without either capability', function () {
$this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator')
->get(route('admin.readiness'))
->assertForbidden();
});
it('lists every check, satisfied or not', function () {
Livewire::actingAs(Operator::factory()->role('Owner')->create())
->test(ReadinessPage::class)
->assertSee(__('readiness.billing.company_details'))
->assertSee(__('readiness.provisioning.usable_host'));
});
it('says what breaks, not only that something is missing', function () {
Livewire::actingAs(Operator::factory()->role('Owner')->create())
->test(ReadinessPage::class)
->assertSee(__('readiness.delivery.mailer_not_log_breaks'));
});
it('runs an active check on demand and shows its answer', function () {
// Die drei Prüfungen aus Task 9 sind nur dann etwas wert, wenn jemand sie
// auslösen kann. Sie laufen NICHT bei jedem Seitenaufruf mit: eine davon
// schreibt einen TXT-Eintrag in die echte Zone.
Livewire::actingAs(Operator::factory()->role('Owner')->create())
->test(ReadinessPage::class)
->call('runCheck', 'provisioning.dns_token')
->assertSet('results.provisioning.dns_token.ok', false);
});
it('refuses to run a check that is not on the list', function () {
// Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
// beliebige Klassen aufzurufen.
Livewire::actingAs(Operator::factory()->role('Owner')->create())
->test(ReadinessPage::class)
->call('runCheck', 'etwas.erfundenes')
->assertSet('results', []);
});
/**
* Der Wächter dieser Spec.
*
* Ein künftig hinzugefügtes Zugangsdatum darf nicht still an der Übersicht
* vorbeigehen. Genau das war der Befund vom 2026-07-30: eine grüne Testsuite
* bei einer Installation, die keinen Host durchinstallieren konnte, weil
* niemand die Liste geführt hat.
*/
it('covers every entry in the secret registry', function () {
$covered = collect(Readiness::all())->pluck('key')->implode(' ');
foreach (array_keys(SecretVault::REGISTRY) as $secret) {
// `stripe.secret` → irgendein Prüfschlüssel, der `stripe_secret` enthält.
$needle = str_replace('.', '_', $secret);
expect($covered)->toContain(
$needle,
"Der Tresor-Eintrag [{$secret}] taucht auf der Bereitschaftsseite nicht auf."
);
}
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: docker compose exec -T app php artisan test --filter=ReadinessPageTest
Expected: FAIL — Route und Komponente existieren nicht
- Step 3: Seite, Route, Navigation und Hinweis schreiben
Komponente App\Livewire\Admin\Readiness mit #[Layout('layouts.admin')],
mount() prüft Gate::any(['hosts.manage', 'secrets.manage']) — dieselbe
Sperre wie die Integrationsseite, weil beide Hälften hier zusammenkommen.
Das Blade zeigt oben die eine Zeile, auf die es ankommt — Bereit für
Testbetrieb bzw. Bereit für Livebetrieb — und darunter die Gruppen. Jeder
Eintrag: Zustand, Beschriftung, der breaks-Satz, und ein Link auf
route('admin.integrations', ['tab' => $check->tab]).
Icons nach R18 (size-4 in der Liste). Zeiten der Herzschläge nach R19 durch
->local().
Die drei Prüfungen aus Task 9 bekommen einen Knopf — und laufen nur auf
Knopfdruck. DnsTokenCheck schreibt einen TXT-Eintrag in die echte Zone; das
bei jedem Seitenaufruf zu tun, wäre eine Prüfung, die selbst ein Risiko ist:
/** Prüfschlüssel → Klasse. Die Liste IST die Erlaubnis. */
private const RUNNABLE = [
'provisioning.dns_token' => DnsTokenCheck::class,
'onboarding.wg_hub' => WireguardEndpointCheck::class,
'provisioning.vm_template' => VmTemplateCheck::class,
'billing.stripe_secret' => StripeCheck::class,
];
/** @var array<string, array<string, mixed>> */
public array $results = [];
public function runCheck(string $key): void
{
$this->authorize('secrets.manage');
// Der Schlüssel kommt aus dem Browser. Ohne diese Zeile wäre das ein Weg,
// beliebige Klassen aufzurufen.
$class = self::RUNNABLE[$key] ?? null;
if ($class === null) {
return;
}
$this->results[$key] = app($class)->run();
}
In Overview ein Hinweis, sobald Readiness::blocking() nicht leer ist, mit
der Anzahl und einem Link auf die Seite.
- Step 4: Tests laufen lassen, Erfolg bestätigen
Run: docker compose exec -T app php artisan test --filter=ReadinessPageTest
Expected: PASS, 6 Tests
- Step 5: Die ganze Suite laufen lassen
Run: docker compose exec -T app php artisan test
Expected: PASS. Bricht alles auf einmal zusammen, ist das fast immer eine Datei
der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man
sucht.
- Step 6:
pintnur auf die eigenen Pfade
docker compose exec -T app ./vendor/bin/pint app/Support/Readiness.php app/Support/Readiness/ app/Livewire/Admin/Readiness.php
Nie --dirty — das Repo ist unter der Standardvorgabe nicht durchgängig
formatiert und würde fremde Dateien umschreiben.
- Step 7: Committen
git add -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php
git commit -F - -- app/Livewire/Admin/Readiness.php resources/views/livewire/admin/readiness.blade.php routes/web.php resources/views/layouts/admin.blade.php app/Livewire/Admin/Overview.php resources/views/livewire/admin/overview.blade.php lang/de/readiness.php tests/Feature/ReadinessPageTest.php
Nachricht:
Put every missing field on one page before a customer pays
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abschluss
VERSIONanheben und taggen. Ein Update auf dem Liveserver greift nur bei einemv*-Tag.
git add -- VERSION
git commit -F - -- VERSION <<'EOF'
Release 1.4.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
git tag v1.4.0
- Push mit Token (
GIT_ACCESS_TOKENaus der.env; ein einfachesgit pushscheitert):
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main --tags
- Auf dem Liveserver die Bereitschaftsseite durchgehen, bis oben Bereit für Livebetrieb steht. Das ist der eigentliche Abnahmetest dieses Vorhabens — nicht die grüne Suite.
Was danach offen bleibt
- Teil C — Testdaten markieren, eigene Belegserie, Ausschluss aus dem Umsatz, Aufräumen der ganzen Kette inkl. VM und DNS. Beschlossen, eigener Entwurf.
EndInstanceServicenimmt Route und DNS-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. - Blöcke A–D aus dem Handoff: goldene Vorlage,
vmbr0, Traefik als Systemdienst.