29 KiB
Host-Übernahme — Plattformseite, 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: CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft.
Architecture: Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die ausschließlich aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen.
Tech Stack: PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose.
Global Constraints
- Vollfassung des Entwurfs:
docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md. Bei Widerspruch gewinnt die Spec. - Die härteste Bedingung, aus §8a: Es entsteht kein einziger neuer Weg von außen — weder auf den Host noch zu CluPilot.
register,progressundroutesliegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind. - Commit-Disziplin, nicht verhandelbar. Weitere Sitzungen arbeiten im selben Repository. 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, keine Kosmetik an fremden Dateien.- R18–R24 aus
CLAUDE.mdgelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken. - Pest-Fallen, beide heute erlebt:
toThrow(EinInterface::class)beweist nichts — immer eine konkrete Klasse. UndtoContain($a, $b)ist variadisch, der zweite Parameter ist kein Meldungstext; dasselbe gilt fürtoHaveKey(). - Testisolation: die Suite läuft mit
CACHE_STORE=array, der zwischen Testdateien nicht geleert wird. Ein Test, der vonSettingsabhängt, setzt den Wert selbst. - Betreiber-Rollen heißen exakt
Owner,Admin,Support,Billing,Read-only,Developer. Fabrik:Operator::factory()->role('Owner')->create(). Testpasswortpasswort-fuer-tests. Guard-Name'operator'— beiLivewire::actingAs($op, 'operator')nicht vergessen. - Tests laufen nur im Container,
docker composeaus/home/nexxo/clupilot:docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test - Immer die volle Suite, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel.
- Kein
migrategegen die Entwicklungsdatenbank. Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher.
Was schon da ist
App\Services\Wireguard\WireguardHub |
allocateIp(), addPeer($pubkey, $ip), removePeer($pubkey), endpoint(), publicKey(), peers() |
config('admin_access.trusted_ranges') |
aus TRUSTED_RANGES, Vorgabe 10.66.0.0/24,127.0.0.1 |
App\Http\Middleware\RestrictConsoleNetwork |
das Vorbild für die Tunnel-Beschränkung — nicht neu erfinden |
App\Services\Secrets\SecretVault |
Tresor mit Modus-Plätzen; stripe.secret ist strikt |
App\Support\Readiness |
fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry |
hosts |
uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at |
SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend) |
die abzulösende Stelle |
ConfigureDnsAndTls |
bildet $hostnames = array_filter([$fqdn, $customDomain]) — beide Namen |
Task 1: Einmal-Code und Schlüsselpaar beim Anlegen
Files:
- Create:
database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php - Create:
app/Support/HostEnrolment.php - Test:
tests/Feature/Host/HostEnrolmentTest.php
Interfaces:
-
Consumes:
WireguardHub::allocateIp(),publicKey(),endpoint(),addPeer(). -
Produces:
HostEnrolment::issue(Host $host): string— legt Code, Schlüsselpaar und Peer an und gibt den Klartext-Code genau einmal zurück.HostEnrolment::claim(string $code): ?Host— löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten aufhosts:enrolment_code_hash,enrolment_expires_at,enrolment_used_at. -
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Host;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;
/**
* Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
* er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
* Gerät im Tunnel für einen fremden Host spricht.
*/
it('hands out the code exactly once and stores only its hash', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect($code)->toHaveLength(32);
$stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
expect($stored)->not->toBe($code)
->and($stored)->not->toContain($code);
});
it('resolves a valid code to its host and consumes it', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
expect(HostEnrolment::claim($code)?->id)->toBe($host->id);
// Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
// eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
expect(HostEnrolment::claim($code))->toBeNull();
});
it('refuses a code past its expiry', function () {
$host = Host::factory()->create();
$code = HostEnrolment::issue($host);
$this->travel(25)->hours();
expect(HostEnrolment::claim($code))->toBeNull();
});
it('refuses a code that was never issued', function () {
expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
});
/**
* Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er
* nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
* Genau das vermeidet dieser Entwurf.
*/
it('allocates a tunnel address and admits the peer up front', function () {
$host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);
HostEnrolment::issue($host);
$host->refresh();
expect($host->wg_ip)->not->toBeNull()
->and($host->wg_pubkey)->not->toBeNull();
expect(app(\App\Services\Wireguard\WireguardHub::class)->peers())
->toContain($host->wg_pubkey);
});
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Run: cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest
Expected: FAIL mit Class "App\Support\HostEnrolment" not found
- Step 3: Migration und Klasse schreiben
Die Migration legt drei Spalten an hosts an — Hash, Ablauf, Verbrauch. Der
private Schlüssel wird nicht gespeichert: er wandert einmal in die
Befehlszeile und ist nach dem Tausch (Task 3) wertlos.
public function issue(Host $host): string
{
$hub = app(WireguardHub::class);
// Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es
// selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel
// steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist
// ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine
// Netzgrenze, die unverändert bleibt.
[$privateKey, $publicKey] = self::keypair();
$ip = $hub->allocateIp();
$hub->addPeer($publicKey, $ip);
$code = Str::random(32);
$host->update([
'wg_ip' => $ip,
'wg_pubkey' => $publicKey,
'enrolment_code_hash' => hash('sha256', $code),
'enrolment_expires_at' => now()->addDay(),
'enrolment_used_at' => null,
]);
// Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die
// Befehlszeile und sonst nirgendwohin.
self::$issuedPrivateKey = $privateKey;
return $code;
}
claim() sucht über den Hash, prüft enrolment_expires_at > now() und
enrolment_used_at === null, setzt enrolment_used_at und gibt den Host zurück.
Hash statt Hash::make: ein sha256 über einen 32-Zeichen-Zufallswert reicht
hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer where-Bedingung
auflösbar, ohne alle Zeilen durchzuprobieren.
- Step 4: Test laufen lassen, Erfolg bestätigen
Run: wie Step 2 Expected: PASS, 5 Tests
- Step 5: Volle Suite
Run: cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test
Expected: PASS
- Step 6: Committen
git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php
git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF'
Let the console admit a host to the tunnel before it exists
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans
Files:
- Create:
app/Http/Middleware/RestrictTunnelOnly.php - Modify:
bootstrap/app.php(Alias registrieren) - Test:
tests/Feature/Host/TunnelOnlyTest.php
Interfaces:
- Consumes:
config('admin_access.trusted_ranges'), das Muster vonRestrictConsoleNetwork. - Produces: Middleware-Alias
tunnel-only.
Lies zuerst app/Http/Middleware/RestrictConsoleNetwork.php. Es löst dieselbe
Frage bereits — CIDR-Vergleich gegen trusted_ranges — und dieser Task soll ihm
folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik
herausziehen lässt, statt sie zu verdoppeln, tu das.
- Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Host;
/**
* Die härteste Bedingung der Spec (§8a): es entsteht kein einziger neuer Weg von
* außen. Dieser Test ist der Grund, warum der Entwurf so aussieht, wie er
* aussieht — er hält fest, dass alle drei Host-Endpunkte unsichtbar bleiben.
*/
beforeEach(function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
});
dataset('host endpoints', [
'register' => ['post', '/host/register'],
'progress' => ['post', '/host/progress'],
'routes' => ['get', '/host/routes'],
]);
it('answers 404 from outside the tunnel', function (string $method, string $path) {
// 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch
// nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost.
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
->call($method, $path)
->assertNotFound();
})->with('host endpoints');
it('is reachable from inside the tunnel', function (string $method, string $path) {
// Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten
// eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift.
$this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
->call($method, $path)
->assertStatus(fn (int $status) => $status !== 404);
})->with('host endpoints');
- Step 2: Test laufen lassen, Fehlschlag bestätigen
Expected: FAIL — die Routen gibt es noch nicht
- Step 3: Middleware und Routen-Gerüst schreiben
RestrictTunnelOnly prüft $request->ip() gegen admin_access.trusted_ranges
und wirft sonst abort(404). Die drei Routen entstehen als Gerüst, das noch
nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5.
- Step 4: Test laufen lassen, Erfolg bestätigen
Expected: PASS, 6 Tests
- Step 5: Volle Suite, dann committen
Nachricht: Make the host endpoints invisible from outside the tunnel
Task 3: POST /host/register — Token in den Tresor, Schlüssel tauschen
Files:
- Create:
app/Http/Controllers/HostRegistrationController.php - Modify:
app/Services/Secrets/SecretVault.php(Registry-Eintrag je Host) - Test:
tests/Feature/Host/HostRegistrationTest.php
Interfaces:
-
Consumes:
HostEnrolment::claim()(Task 1), Tunnel-Middleware (Task 2),WireguardHub::addPeer/removePeer. -
Produces:
hosts.api_token_refgefüllt,hosts.wg_pubkeyauf den frischen Schlüssel, Antwort enthält das dauerhafte Host-Token für den Routen-Abruf. -
Step 1: Den fehlschlagenden Test schreiben
<?php
use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;
beforeEach(function () {
config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
$this->host = Host::factory()->create(['status' => 'onboarding']);
$this->code = HostEnrolment::issue($this->host);
});
function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse
{
return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
->postJson('/host/register', array_merge([
'code' => $code,
'api_token' => 'root@pam!clupilot=abc-123',
'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=',
'node' => 'pve-fsn-1',
'pve_version' => '9.0-1',
'total_gb' => 1000,
'total_ram_mb' => 65536,
'cpu_cores' => 16,
'ssh_host_key' => 'SHA256:abc',
], $payload));
}
it('puts the proxmox token in the vault and never in the response', function () {
$response = registerAs($this->code);
$response->assertOk();
expect($response->json())->not->toContain('root@pam!clupilot=abc-123');
expect($this->host->fresh()->api_token_ref)->not->toBeNull();
});
/**
* Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen.
* Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er
* sitzt, und die Antwort erreicht ihn nie.
*/
it('admits the new key before dropping the old one', function () {
$old = $this->host->fresh()->wg_pubkey;
registerAs($this->code)->assertOk();
$peers = app(WireguardHub::class)->peers();
expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=')
->and($peers)->not->toContain($old);
expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=');
});
it('refuses a code that belongs to another host', function () {
$other = Host::factory()->create();
$otherCode = HostEnrolment::issue($other);
registerAs($otherCode)->assertOk();
// Der fremde Host wurde eingerichtet, DIESER nicht.
expect($this->host->fresh()->api_token_ref)->toBeNull();
});
it('refuses a spent code', function () {
registerAs($this->code)->assertOk();
registerAs($this->code)->assertStatus(422);
});
it('hands back a durable host token for the route pull', function () {
expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty();
});
- Step 2 bis 6: Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen.
Beim Schreiben beachten: Der Proxmox-Token gehört in den Tresor (verschlüsselt
mit SECRETS_KEY), nicht in eine Spalte. hosts.api_token_ref hält nur den
Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung
mit SECRETS_KEY ausdrücklich als das teuerste Einzelstück.
Nachricht: Take the host's token through the tunnel and swap its key
Task 4: POST /host/progress — mit Nachreichen
Files:
- Create:
app/Http/Controllers/HostProgressController.php - Create:
database/migrations/2026_08_02_100000_record_what_a_host_reported.php - Test:
tests/Feature/Host/HostProgressTest.php
Interfaces:
- Produces: Tabelle
host_progress_events(host_id,section,state,message,occurred_at), die Abschnittsliste aus §7 der Spec.
Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind die einzige Absprache zwischen diesem Plan und dem Skript-Plan:
rescue_checked, debian_installed, rebooted, proxmox_installed,
network_bridged, wireguard_joined, traefik_running, template_built,
registered
- Step 1: Den fehlschlagenden Test schreiben
Der wichtigste Fall ist das Nachreichen: alles vor wireguard_joined kann der
Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es
lokal mit und reicht es beim ersten erreichbaren Aufruf nach.
it('keeps the reported timestamps, not the arrival time', function () {
// Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
// Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat.
$reported = now()->subMinutes(18);
postProgress($this->code, [
['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()],
['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()],
])->assertOk();
expect($this->host->progressEvents()->first()->occurred_at->toIso8601String())
->toBe($reported->copy()->subMinutes(6)->toIso8601String());
});
it('refuses a section it does not know', function () {
// Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die
// Fortschrittsanzeige mit erfundenen Zeilen.
postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']])
->assertStatus(422);
});
- Steps 2 bis 6 wie gehabt. Nachricht:
Let the host tell the console how far it got
Task 5: GET /host/routes — Traefik holt sich seine Tabelle
Files:
- Create:
app/Http/Controllers/HostRoutesController.php - Create:
app/Services/Traefik/RouteTable.php - Test:
tests/Feature/Host/HostRoutesTest.php
Interfaces:
-
Consumes: das dauerhafte Host-Token aus Task 3,
Instancemitsubdomain,custom_domain,routed_hostnames. -
Produces:
RouteTable::forHost(Host $host): array— Traefiks dynamische Konfiguration für genau diesen Host. -
Step 1: Den fehlschlagenden Test schreiben
it('serves only the instances of this host', function () {
// Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller.
$mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']);
$theirs = Instance::factory()->create(['subdomain' => 'fremde']);
$body = json_encode(fetchRoutes($this->hostToken)->json());
expect($body)->toContain('meine')->not->toContain('fremde');
});
it('serves both the subdomain and a proven custom domain', function () {
// Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls.
Instance::factory()->create([
'host_id' => $this->host->id,
'subdomain' => 'mueller',
'custom_domain' => 'cloud.mueller-gmbh.de',
'domain_verified_at' => now(),
]);
$body = json_encode(fetchRoutes($this->hostToken)->json());
expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de');
});
it('leaves out a custom domain that was never proven', function () {
Instance::factory()->create([
'host_id' => $this->host->id,
'custom_domain' => 'nicht-bewiesen.de',
'domain_verified_at' => null,
]);
expect(json_encode(fetchRoutes($this->hostToken)->json()))
->not->toContain('nicht-bewiesen.de');
});
Beim Schreiben: Die Entrypoint- und certResolver-Namen müssen zu dem passen,
was SshTraefikWriter::render() heute ausgibt. Der Handoff warnt in Block C
ausdrücklich: „hier zuerst nachlesen, nicht raten." Das gilt unverändert.
- Steps 2 bis 6. Nachricht:
Serve a host the routes it is supposed to answer for
Task 6: SshTraefikWriter ablösen
Files:
- Modify:
app/Services/Traefik/(neuerTraefikWriter, der in die Datenbank schreibt) - Delete:
app/Services/Traefik/SshTraefikWriter.php - Test:
tests/Feature/Host/NoShellForRoutesTest.php
Interfaces:
-
Die Aufrufer (
ConfigureDnsAndTls,RunAcceptanceChecks,EndInstanceService) ändern sich nicht. Sie rufen weiterTraefikWriter::write(...); nur was dahinter passiert, ändert sich. -
Step 1: Den fehlschlagenden Test schreiben
/**
* Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts
* braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der
* Entwurf nicht eingelöst — egal wie grün alles andere ist.
*/
it('opens no shell when an address is configured', function () {
$s = fakeServices();
$instance = Instance::factory()->create(['host_id' => $this->host->id]);
app(ConfigureDnsAndTls::class)->execute(runFor($instance));
expect($s['shell']->connectionsWith('key'))->toBeEmpty();
});
it('has no SshTraefikWriter left in the repository', function () {
expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse();
});
- Steps 2 bis 6. Nachricht:
Stop reaching into customer hosts to write a route
Task 7: Adminbereich — Befehlszeile und Fortschritt
Files:
-
Modify:
app/Livewire/Admin/HostCreate.php,HostDetail.phpund deren Blades -
Test:
tests/Feature/Admin/HostEnrolmentPageTest.php -
Step 1: Den fehlschlagenden Test schreiben
it('shows the command exactly once, right after creating the host', function () {
// Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt,
// bekommt ihn nicht wieder — er legt einen neuen an.
$page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-2')
->set('datacenter', 'fsn')
->set('publicIp', '198.51.45.9')
->call('create');
$page->assertSee('curl');
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid])
->assertDontSee('curl');
});
it('shows which section the host is stuck in', function () {
// „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist,
// ist eine.
$this->host->progressEvents()->create([
'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30),
]);
Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostDetail::class, ['uuid' => $this->host->uuid])
->assertSee(__('hosts.section.network_bridged'));
});
R18, R19, R23, R24 gelten — Blades werden angefasst. Die vier Wächter-Tests am
Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch
->local().
- Steps 2 bis 6. Nachricht:
Give the operator one line to copy and a progress to watch
Task 8: Die Subdomain gehört dem Kunden
Files:
- Modify:
app/Livewire/Order.phpbzw. die Kasse,app/Provisioning/Steps/Customer/ReserveResources.php - Create:
app/Rules/AvailableSubdomain.php - Test:
tests/Feature/SubdomainChoiceTest.php
Interfaces:
-
Heute:
ReserveResources::uniqueSubdomain()bildetStr::slug($customer->name).'-'.Str::random(5). Künftig kommt die Subdomain aus dem Auftrag; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch. -
Step 1: Den fehlschlagenden Test schreiben
it('refuses a name the platform needs itself', function (string $name) {
expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes())
->toBeFalse();
})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']);
it('refuses a name another customer already has, whatever the case', function () {
Instance::factory()->create(['subdomain' => 'mueller']);
expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes())
->toBeFalse();
});
it('falls back to a neutral name when the field is left empty', function () {
// Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das
// Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs.
$order = orderWithoutSubdomainWish(customerNamed('Anna Müller'));
$subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain');
expect($subdomain)->not->toContain('mueller');
});
it('keeps the name the customer chose', function () {
$order = orderWishing('meine-wolke');
expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain'))
->toBe('meine-wolke');
});
- Steps 2 bis 6. Der Hinweis neben dem Feld muss sagen, dass die Adresse
öffentlich und dauerhaft ist. Nachricht:
Let the customer pick the address they will keep
Task 9: Die Kette schrumpfen
Files:
- Modify:
config/provisioning.php - Delete:
app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.phpund ihre Tests - Modify:
app/Provisioning/Steps/Host/SecureHostFirewall.php— vom Schreiben zum Prüfen - Test:
tests/Feature/Provisioning/HostPipelineShapeTest.php
HALT — die einzige echte Kopplung zwischen den zwei Plänen.
Diese sieben Dateien sind die Quelle für den Skript-Plan. Sie enthalten Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für Debian 13/PVE 9, die vollständige
vmbr0-Diagnose, die nftables-Regeln samt der ICMP-Korrektur, die systemd-Aktivierung vonwg0, und die Proxmox-Rolle inklusiveSys.Modify, ohne das jede Kundenbereitstellung am Backup-Schritt stirbt.Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben sind — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus
git logsind sie zwar wiederherstellbar, aber niemand liest die Historie, wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse verloren, die einmal einen Ausfall gekostet haben.
- Step 1: Den fehlschlagenden Test schreiben
it('has six steps that only look, never change', function () {
expect(config('provisioning.pipelines.host'))->toBe([
Host\ValidateHostInput::class,
Host\VerifyProxmoxApi::class,
Host\VerifyVmTemplate::class,
Host\RegisterHostDns::class,
Host\RegisterCapacity::class,
Host\CompleteHostOnboarding::class,
]);
});
it('opens no shell anywhere in the host pipeline', function () {
// Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der
// Proxmox-Token.
$s = fakeServices();
foreach (config('provisioning.pipelines.host') as $step) {
app($step)->execute($this->run);
}
expect($s['shell']->connectionsWith('key'))->toBeEmpty()
->and($s['shell']->connectionsWith('password'))->toBeEmpty();
});
Achtung: SecureHostFirewall bleibt, wird aber zur Prüfung. Ob es dafür noch
in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der
Implementierer — begründet im Bericht.
- Steps 2 bis 6. Nachricht:
Shrink the host pipeline to six steps that only look
Task 10: Bereitschaftsprüfungen nachziehen
Files:
- Modify:
app/Support/Readiness/OnboardingChecks.php,ProvisioningChecks.php - Modify:
lang/de/readiness.php,lang/en/readiness.php - Test:
tests/Feature/Readiness/OnboardingChecksTest.php,ProvisioningChecksTest.php
Zu ändern, aus §10 der Spec:
onboarding.ssh_private_key— nicht mehr blockierend. Die Plattform braucht keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein Fehlen hält nichts auf.provisioning.traefik_path— entfällt. Es gibt kein Verzeichnis mehr, in das CluPilot schreibt.- Neu: mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert stillschweigend einen veralteten Stand.
Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder Tresor-Eintrag auf der Bereitschaftsseite auftaucht. Wer hier einen Eintrag entfernt oder umbenennt, zieht ihn nach.
- Steps 1 bis 6. Nachricht:
Teach the readiness page that nobody writes to a host any more
Abschluss
- Volle Suite grün.
VERSIONnicht anheben, kein Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen.- Branch pushen:
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap
Was danach offen bleibt
- Der Skript-Plan (
2026-07-30-host-uebernahme-bootstrap-skript.md) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4. - Die Abnahme auf echter Hardware. Sie ist die einzige, die zählt.
- Die zwei Seed-Hosts aus
DatabaseSeederentfernen. - Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.