20 KiB
Host-Onboarding (Subsystem A) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development per task. Steps use checkbox (
- [ ]) syntax.
Goal: Ein Admin trägt einen frischen Debian-Server (Root-SSH) im Panel ein; CluPilot bringt ihn vollautomatisch als Proxmox-Host (active, mit Kapazität + API-Token) in die Flotte.
Architecture: Gemeinsamer DB-State-Machine + Tick-Orchestrator-Kern (Ansatz ①, wiederverwendbar für Subsystem B). Host-Onboarding = pipeline=host aus 11 idempotenten Step-Klassen. I/O (SSH/WireGuard/Proxmox) hinter Interfaces → in Tests Fakes.
Tech Stack: Laravel 13.8, Livewire 3 (class-based), phpseclib3, Reverb, Redis-Queue, Pest 4, sqlite :memory: (Tests), Tailwind v3 Tokens.
Global Constraints
- R1/R2: Full-page class-based Livewire als Routen (kein Volt). R3: nur Token-Utilities, kein Hex. R4: keine Inline-Styles außer Progress-
width. R5: destruktiv → wire-elements/modal. R7: responsive 375/768/1280, Touch ≥44px. R8: alles im Container. R9: UI-Copy DE, keine Emoji (Status via Farbe/Dots/Pills). R10: Design-System wiederverwenden. R11: URLs per UUID. R12: Browser 0 Konsolenfehler. R13: Routen-Pfade/Namen englisch. R14: Fonts self-hosted. R15: Codex clean vor „fertig". R16: alles DE+EN, identische Keys. R17: Blade-@php-Regeln. - Tests gegen sqlite
:memory:(phpunit.xmlforce="true"— nicht kaputt machen). - Secrets nie in Git;
api_token_refCrypt-verschlüsselt; Root-Passwort transient + gescrubbt. - Testlauf:
docker compose run --rm --no-deps -u 1000:1000 -e HOME=/tmp app ./vendor/bin/pest
File Structure
Domäne / Models
app/Models/Host.php,app/Models/ProvisioningRun.php,app/Models/ProvisioningStepEvent.php,app/Models/RunResource.php- Migrations:
*_create_hosts_table,*_create_provisioning_runs_table,*_create_provisioning_step_events_table,*_create_run_resources_table
Orchestrator-Kern (app/Provisioning/)
StepResult.php(Wert),Contracts/ProvisioningStep.php(Interface)PipelineRegistry.php(pipeline → Step-Klassen)RunRunner.php(eine Advance-Ausführung: Lock, Step, Result, Event, Broadcast)Jobs/AdvanceRunJob.phpEvents/StepAdvanced.php(Broadcast)
Services (app/Services/)
Ssh/RemoteShell.php(Interface),Ssh/CommandResult.php,Ssh/PhpseclibRemoteShell.php,Ssh/FakeRemoteShell.phpWireguard/WireguardHub.php(Interface),Wireguard/LocalWireguardHub.php,Wireguard/FakeWireguardHub.phpProxmox/ProxmoxClient.php(Interface),Proxmox/HttpProxmoxClient.php,Proxmox/FakeProxmoxClient.php
Host-Steps (app/Provisioning/Steps/Host/): ValidateHostInput, EstablishSshTrust, PrepareBaseSystem, ConfigureWireguard, InstallProxmoxVe, RebootIntoPveKernel, ConfigureProxmox, CreateAutomationToken, VerifyProxmoxApi, RegisterCapacity, CompleteHostOnboarding
Orchestrierung-Anbindung
routes/console.php(Tick-Schedule),app/Actions/StartHostOnboarding.php(Host+Run anlegen+dispatch)
Admin-UI
app/Livewire/Admin/Hosts.php(Fixture→echt) +resources/views/livewire/admin/hosts.blade.phpapp/Livewire/Admin/HostDetail.php+resources/views/livewire/admin/host-detail.blade.phpapp/Livewire/Admin/HostCreate.php(Formular) + Viewroutes/web.php(+/admin/hosts/{uuid}, add-host route)lang/{de,en}/hosts.php
Tests (tests/Feature/..., tests/Unit/...): je Kern-Komponente + je Step + Admin-Gate/CRUD.
Global Interfaces (verbatim — von allen Tasks konsumiert)
// app/Provisioning/StepResult.php
final class StepResult {
public const ADVANCE = 'advance';
public const RETRY = 'retry';
public const FAIL = 'fail';
public string $type; public int $afterSeconds; public string $reason;
public static function advance(): self;
public static function retry(int $afterSeconds, string $reason): self;
public static function fail(string $reason): self;
}
// app/Provisioning/Contracts/ProvisioningStep.php
interface ProvisioningStep {
public function key(): string; // stabil, snake_case
public function label(): string; // i18n-Key 'hosts.step.<key>'
public function maxDuration(): int; // Sekunden
public function execute(ProvisioningRun $run): StepResult;
}
// app/Services/Ssh/CommandResult.php
final class CommandResult { public int $exitCode; public string $stdout; public string $stderr;
public function ok(): bool; /* exitCode === 0 */ }
// app/Services/Ssh/RemoteShell.php
interface RemoteShell {
public function connectWithPassword(string $host, string $user, string $password): void;
public function connectWithKey(string $host, string $user, string $privateKey): void;
public function run(string $command): CommandResult;
public function putFile(string $remotePath, string $contents): void;
public function hostKeyFingerprint(): string;
}
// app/Services/Wireguard/WireguardHub.php
interface WireguardHub {
public function allocateIp(): string; // z.B. 10.66.0.7
public function addPeer(string $publicKey, string $ip): void;
public function removePeer(string $publicKey): void;
public function endpoint(): string; // Hub public endpoint host:port
public function publicKey(): string; // Hub WG pubkey
}
// app/Services/Proxmox/ProxmoxClient.php
interface ProxmoxClient {
public function forHost(Host $host): static; // Basis-URL + Token aus Host
public function listNodes(): array;
public function nodeStatus(string $node): array; // cpuinfo.cpus, memory.total ...
public function nodeStorage(string $node): array;
public function createRole(string $roleId, string $privs): void;
public function createUserAndToken(string $user, string $roleId): array; // ['token_id','secret']
}
ProvisioningRun-Status: pending, running, waiting, paused, completed, failed. Felder: uuid, subject_type, subject_id, pipeline, current_step (int, 0-basiert), status, attempt, max_attempts, next_attempt_at, started_at, finished_at, error, context (json).
Host-Status: pending, onboarding, active, error, disabled.
Phase 1 — Datenmodell
Task 1: Migrations + Models (hosts, runs, step_events, run_resources)
Files: Create 4 Migrations + 4 Models. Test: tests/Unit/ModelsTest.php.
Produces: Eloquent-Models mit Casts (context→array/json, api_token_ref→encrypted, next_attempt_at/started_at/finished_at/last_seen_at→datetime), Host::uuid/ProvisioningRun::uuid auto (booted creating), Relationen (run->events, run->resources, run->subject() morphTo).
- Step 1: Test: Host mit
uuiderzeugen → uuid gesetzt,api_token_refroundtrip verschlüsselt (DB-Rohwert ≠ plaintext). ProvisioningRuncontextarray-cast. StepEvent append (kein updated_at). RunResource FK. - Step 2:
pest tests/Unit/ModelsTest.php→ FAIL. - Step 3: Migrations + Models schreiben (Felder s. Spec §3).
uuidviaHasUuid-Trait oderbooted. StepEvent$timestamps=false+created_atmanuell /useCurrent. - Step 4:
pest tests/Unit/ModelsTest.php→ PASS. Voller Lauf grün. - Step 5: Commit
feat(engine): core provisioning data model + hosts.
Phase 2 — Orchestrator-Kern (test-first, mit Fake-Steps)
Task 2: StepResult
- Test:
advance()->type===ADVANCE;retry(30,'x')->afterSeconds===30;fail('y')->reason==='y'. → FAIL → implement → PASS → Commit.
Task 3: ProvisioningStep-Interface + PipelineRegistry
Produces: PipelineRegistry::stepsFor(string $pipeline): array (Klassennamen), ::resolve($pipeline,$index): ProvisioningStep (aus Container). Registrierung in config/provisioning.php ('pipelines'=>['host'=>[...]]).
- Test mit 2 Fake-Steps (
tests/Fixtures/FakeAdvanceStep,FakeRetryStep): Registry liefert korrekte Klasse je Index; unbekannte Pipeline → Exception. - Implement → PASS → Commit.
Task 4: RunRunner (Herzstück)
Consumes: StepResult, ProvisioningStep, PipelineRegistry, Models.
Produces: RunRunner::advance(ProvisioningRun $run): void.
Verhalten: Lock run:{uuid}; wenn status nicht in (running,waiting,pending) → return; Step auflösen (current_step); Timeout: waiting+started_at+maxDuration<now → als Fehler zählt zur Retry-Politik; execute in try/catch (Exception → wie retry mit Backoff bis max_attempts, dann fail); Event schreiben (outcome, message); StepResult anwenden (advance→current_step++, letzter→completed+finished_at+Host active bleibt Step-Sache; retry→attempt++/next_attempt_at/status=waiting oder fail bei max; fail→failed+error); nach advance (nicht letzter) AdvanceRunJob::dispatch.
- Step 1: Tests:
- advance-Step →
current_step+1, Eventoutcome=advanced, Folge-Job dispatched (Queue::fakeassertPushed). - letzter advance-Step →
status=completed,finished_atgesetzt. - retry-Step →
status=waiting,attempt=1,next_attempt_at≈now+afterSeconds, kein Sofort-Job. - retry bis
max_attempts→status=failed. - fail-Step →
status=failed,errorgesetzt. - Exception im Step → behandelt wie retry (kein Fatal-Leak).
- Lock: zweiter paralleler
advanceno-op (Lock gehalten) — viaCache::lockgehaltenem Lock simulieren.
- advance-Step →
- Implement → alle PASS → Commit.
Task 5: AdvanceRunJob + Tick
Produces: AdvanceRunJob(string $runUuid) (queue provisioning) → RunRunner::advance. Tick in routes/console.php: Schedule::call(fn()=> dispatch alle runs status in (running,waiting) && next_attempt_at<=now)->everyMinute() — als invokable App\Console\TickProvisioning testbar.
- Test: Tick-Action dispatcht Job nur für fällige Runs (waiting+due), nicht für completed/paused/future.
Queue::fake. - Implement → PASS → Commit.
Task 6: StepAdvanced Broadcast-Event
Produces: StepAdvanced implements ShouldBroadcast, PrivateChannel('admin.runs') (+ später kunden-scoped). Payload: run uuid, step key, outcome, current_step, status.
- Test: Event broadcastet auf erwartetem Kanal mit Payload (
Event::fake, RunRunner feuert es). Channel-Authz inroutes/channels.php(nuris_admin). - Implement → PASS → Commit.
Phase 3 — Service-Interfaces + Fakes + reale Impl
Task 7: RemoteShell (Interface, CommandResult, Fake, phpseclib-Impl)
Fake: FakeRemoteShell mit script(string $cmdSubstring, CommandResult|callable), recorded() (ausgeführte Kommandos), Default ok(''). putFile merkt files().
- Test (Fake):
run('proxmox-ve installed?')liefert skript-Antwort; unbekannt → Default ok.recorded()enthält Kommandos.putFilespeichert. - Implement Fake → PASS.
PhpseclibRemoteShell(dünn, kein Unit-Test des echten I/O; nur Klassenexistenz + Konstruktion) → Commit.
Task 8: WireguardHub (Interface, Fake, LocalWireguardHub)
Fake: FakeWireguardHub (allocateIp inkrementell aus Config-Pool, peers()-Liste, endpoint/publicKey fest).
- Test (Fake):
allocateIp()vergibt fortlaufend;addPeererscheint inpeers();removePeerentfernt. - Implement Fake +
LocalWireguardHub(schreibt viaRemoteShellauf localhost / config-Pfad — Watch-Item, dünn) → Commit.
Task 9: ProxmoxClient (Interface, Fake, HttpProxmoxClient)
Fake: FakeProxmoxClient (forHost merkt Host; listNodes konfigurierbar; nodeStatus/nodeStorage liefern feste Kapazität; createUserAndToken liefert ['token_id'=>'automation@pve!clupilot','secret'=>'s3cr3t'], merkt createdToken()).
- Test (Fake): Kapazitäts-/Token-Rückgaben;
forHostimmutability (neue Instanz je Host ok). - Implement Fake +
HttpProxmoxClient(LaravelHttpmit Token-Header,withoutVerifyingfür self-signed über WG — dünn) → Commit.
Binding: AppServiceProvider bindet Interfaces → reale Impl in prod, Fakes in Tests via $this->app->instance(...) (Test-Setup-Helper bindFakeServices() in tests/Pest.php).
Phase 4 — Host-Steps 1–4
Jeder Step: Test-first gegen Fakes. Muster je Step: (a) Idempotenz (zweiter Lauf → keine Doppel-Ressource, advance), (b) Happy-Path advance, (c) Fehler → retry/fail korrekt, (d) externe ID [E] vor advance in run_resources.
Task 10: ValidateHostInput
Prüft context.root_password vorhanden, Host hat public_ip+datacenter, TCP:22 (über RemoteShell-Connect-Versuch oder simplen Port-Check via Fake). Ungültig → fail. Host→onboarding.
- Tests: fehlendes Passwort→fail; gültig→advance + Host status onboarding. Implement → PASS → Commit.
Task 11: EstablishSshTrust
connectWithPassword; putFile ~/.ssh/authorized_keys (append CluPilot pubkey aus config services.clupilot.ssh_public_key); hostKeyFingerprint→host.ssh_host_key [E]; connectWithKey verifizieren; context.root_password entfernen (unset + save). Idempotenz: key-login ok → skip password-Phase.
- Tests: happy→advance,
ssh_host_keygesetzt,contexthat keinroot_passwordmehr; key schon vorhanden→advance ohne Passwort-Connect; connect-Fehler→retry. Implement → PASS → Commit.
Task 12: PrepareBaseSystem
Kommandos: hostname/FQDN, /etc/hosts, apt-get update, apt-get install -y curl gnupg ifupdown2 chrony. Fehlerhafter apt-exit → retry. Idempotent (Kommandos sind es).
- Tests: happy→advance (recorded enthält apt-get update); apt-Fehler→retry. Implement → PASS → Commit.
Task 13: ConfigureWireguard
Hub: allocateIp (wenn host.wg_ip leer), Host-Keypair via wg genkey (RemoteShell), Pubkey lesen, addPeer(pubkey, wg_ip), wg0.conf putFile, wg-quick up, Handshake-Verify (ping -c1 hub-wg-ip ok). host.wg_ip+wg_pubkey [E], run_resources kind=wg_peer external_id=pubkey. Idempotent: wg_ip schon gesetzt + Peer existiert → advance.
- Tests: happy→advance, wg_ip+wg_pubkey gesetzt, run_resource
wg_peergenau einmal bei Doppellauf; ping-Fehler→retry. Implement → PASS → Commit.
Phase 5 — Host-Steps 5–7
Task 14: InstallProxmoxVe
Idempotenz: dpkg -l proxmox-ve ok → advance. Sonst: PVE-Repo + Key (putFile /etc/apt/sources.list.d/pve.list + key), Enterprise-Repo deaktivieren, apt-get update, apt-get -y full-upgrade, apt-get -y install proxmox-ve postfix open-iscsi. maxDuration groß (z.B. 1800). apt-Netzfehler→retry; Repo/Key-Fehler→fail.
- Tests: bereits installiert→advance (kein install-Kommando); frisch→advance (recorded enthält install); apt-Fehler→retry. Implement → PASS → Commit.
Task 15: RebootIntoPveKernel
Wenn context.reboot_issued fehlt: apt-get -y remove linux-image-amd64, update-grub, reboot, context.reboot_issued=true, retry(30,'reboot'). Sonst Poll: connectWithKey + uname -r endet auf -pve → advance; sonst retry(15,'waiting for pve kernel'); Deadline (context.reboot_deadline) überschritten → fail.
- Tests: erste Ausführung→retry + reboot_issued gesetzt + recorded enthält reboot; Poll nicht-pve→retry; Poll pve→advance; Deadline über→fail. Implement → PASS → Commit.
Task 16: ConfigureProxmox
vmbr0 sicherstellen (Check ip link show vmbr0; fehlt → minimal anlegen), Datacenter-Firewall-Baseline, No-Subscription-Nag (optional, best-effort ok). Idempotent.
- Tests: happy→advance; vorhandene Bridge→kein Neu-Anlegen. Implement → PASS → Commit.
Phase 6 — Host-Steps 8–11
Task 17: CreateAutomationToken
Idempotenz: run_resources kind=pve_token vorhanden + host.api_token_ref gesetzt → advance. Sonst ProxmoxClient::forHost($host)->createRole('CluPilotAutomation', <privs>) (idempotent/ignore-exists), createUserAndToken('automation@pve','CluPilotAutomation') → ['token_id','secret'] → host.api_token_ref = token_id . '=' . secret (encrypted cast) vor advance, run_resources kind=pve_token external_id=token_id. Crash-vor-Persistenz: Token-Marker fehlt → neu (alte via delete best-effort).
- Tests: happy→advance, api_token_ref gesetzt (entschlüsselt enthält secret), run_resource genau einmal bei Doppellauf; bereits vorhanden→advance ohne createUserAndToken-Call. Implement → PASS → Commit.
Task 18: VerifyProxmoxApi
ProxmoxClient::forHost($host)->listNodes() nicht leer → advance; leer/Fehler → retry (kurze Deadline) dann fail.
- Tests: nodes→advance; leer→retry. Implement → PASS → Commit.
Task 19: RegisterCapacity
nodeStatus+nodeStorage → host.cpu_cores, total_ram_mb, total_gb, cpu_weight (=cores), pve_version, last_seen_at=now. Idempotent (überschreibt).
- Tests: Kapazität geschrieben aus Fake-Werten. Implement → PASS → Commit.
Task 20: CompleteHostOnboarding
host.status='active'; letzter Step (advance → Run completed via Runner).
- Tests: host active; Run über Runner→completed. Implement → PASS → Commit.
Task 21: End-to-End (gemockt) durch die ganze Pipeline
StartHostOnboarding::run($input) legt Host+Run an, dispatcht; dann alle Ticks/Jobs synchron abarbeiten (Queue::fake aus, sync driver in Test) bis completed. Assert: Host active, Kapazität gesetzt, run_resources (wg_peer, pve_token) je einmal, keine root_password-Reste. Plus Crash-Idempotenz-Test: Run nach Step X abbrechen, erneut ticken → keine Doppel-Ressource.
- Tests → PASS → Commit.
Phase 7 — Admin-UI + Anbindung
Task 22: StartHostOnboarding-Action + Route/Livewire HostCreate
Produces: StartHostOnboarding::run(array $input): Host. Admin\HostCreate Livewire-Formular (name, datacenter select, public_ip, root_password) → Validierung → Action → redirect /admin/hosts/{uuid}. Route /admin/hosts/create (name admin.hosts.create).
- Tests: Gast→login, Nicht-Admin→403, Admin submit→Host+Run angelegt, dispatch (Queue::fake assertPushed AdvanceRunJob), redirect. Implement → PASS → Commit.
Task 23: Hosts-Liste echt
Admin\Hosts Fixture→Host::all() mit Status-Pills (Farbe), Datacenter, Kapazität frei/total, Link je Host (uuid), „Host hinzufügen"-Button. lang/{de,en}/hosts.php.
- Tests: rendert echte Hosts; Gate. R16 Keys DE+EN. Implement → PASS → Commit.
Task 24: HostDetail + Live-Stepper (Reverb)
Admin\HostDetail (route /admin/hosts/{uuid}, name admin.hosts.show): lädt Host+aktiven Run+Events, baut steps[] (label aus i18n, state aus current_step/status) für x-ui.progress-stepper; Livewire-Listener auf Reverb StepAdvanced (#[On('echo-private:admin.runs,StepAdvanced')]) → $refresh. Fehler-Panel + „Erneut versuchen" (Action: run status=running/next_attempt_at=now/dispatch). „Host entfernen" (wire-elements/modal → delete record, R5).
- Tests: rendert Stepper aus Run-State; Retry-Action setzt Run + dispatch; Delete entfernt Host (kein Server-Wipe); Gate. Implement → PASS → Commit.
Phase 8 — Verifikation
Task 25: Lokalisierung, R12, R15
- DE+EN vollständig, identische Keys (
hosts.php,dashboard.step_state.*ggf. erweitern für Host-Steps → eigenerhosts.step.*-Namespace). - Voller Pest-Lauf grün.
- R12:
npm run build→ Vite stop +rm public/hot→ Puppeteer über/admin/hosts,/admin/hosts/create,/admin/hosts/{uuid}(authed admin) → HTTP 200 + 0 Konsolenfehler →docker compose restart app. - R15: Codex-Review
--scope working-tree --background, Loop bis „no actionable regressions". - Seeder: 1–2 Demo-Hosts (active) für lokale Ansicht, gated local/testing.
- Commit(s), push (Token inline).
Self-Review
Spec coverage: §3 Datenmodell→T1; §4 Kern→T2-6; §5 Steps 1-11→T10-20; §6 Services→T7-9; §7 Admin-UI→T22-24; §8 Fehler/Rollback→T24 (Retry/Delete) + Runner (T4); §9 Tests→jede Task test-first + T21/T25; §11 Watch-Items→in Tasks vermerkt (WG-Schreibweg T8, Kernel/Reboot T15, Deadlines T15/18); §12 DoD→T21+T25. Vollständig abgedeckt.
Placeholder-Scan: keine TBD/„handle edge cases" — jede Task hat konkrete Bedingungen + Tests.
Type-Konsistenz: StepResult::advance/retry/fail, ProvisioningStep::execute/key/label/maxDuration, RemoteShell::run/putFile, WireguardHub::allocateIp/addPeer, ProxmoxClient::forHost/createUserAndToken durchgängig identisch verwendet.