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

85 KiB
Raw Blame History

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> und git commit -F - -- <pfade>. Nie git add -A, git add ., git commit -a oder ein nacktes git commit.
  • pint nur auf eigene Pfade, nie --dirty. Das Repo ist unter der Standardvorgabe nicht durchgängig formatiert.
  • R22: eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach wird ein offener Befund geparkt.
  • R23: kein wire:confirm, kein confirm( in JavaScript. Bestätigung im Modal.
  • R24: jedes Modal mit einem Eingabefeld benutzt <x-ui.modal>.
  • R18: Icons size-4 in Tabellen und Buttons, size-5 in 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 von phpunit.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älle OperatingMode::Test und OperatingMode::Live, ->value als '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): ?stringSignatur 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). null heiß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 (liefert null).

  • Produces: App\Exceptions\StripeNotConfigured (konkrete Klasse — eine Schnittstelle im toThrow() 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) und config('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 blocking
    • Readiness::isReady(): bool
    • BillingChecks::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: Check und checkFor()-Muster aus Task 7.
  • Produces: OnboardingChecks::all(), ProvisioningChecks::all(), DeliveryChecks::all() — je array<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 class mit run(): array in genau der Form, die StripeCheck schon 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 okgenau 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, OperationChecks folgt dem Muster aus Task 7.
  • Produces: Settings-Schlüssel heartbeat.scheduler und heartbeat.queue_provisioning, je ein ISO-8601-Zeitstempel. Prüfungen operation.scheduler und operation.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 bestehende openModal-Muster.
  • Produces: Livewire-Ereignis mode-switch-confirmed mit Argument mode.

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.php und 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: pint nur 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

  • VERSION anheben und taggen. Ein Update auf dem Liveserver greift nur bei einem v*-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_TOKEN aus der .env; ein einfaches git push scheitert):
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. EndInstanceService nimmt 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 AD aus dem Handoff: goldene Vorlage, vmbr0, Traefik als Systemdienst.