18 KiB
Design-Spec — Subsystem A: Host-Onboarding / Proxmox-Bootstrap (v1.0)
Datum: 2026-07-25
Art: Feature-Design (Brainstorming → Spec)
Kontext-Dokumente: docs/handoffs/2026-07-25-clupilot-state-handoff.md (Zustand/Workflow/Regeln), docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md (Subsystem B, teilt das Fundament).
1. Ziel & Abgrenzung
Ziel: Ein Admin trägt im Panel einen frischen Server (Debian, nur Root-SSH) ein. CluPilot bringt ihn vollautomatisch in Betrieb: SSH-Vertrauen → WireGuard-Tunnel zum Hub (CluPilot-VM) → Proxmox VE installieren → Reboot in PVE-Kernel → Proxmox-Grundkonfiguration → automation@pve-API-Token erzeugen → Kapazität registrieren → Host active. Danach ist der Host für die Kunden-Provisionierung (Subsystem B) platzierbar.
A endet, wo B anfängt: A installiert per SSH und übergibt am Ende einen Host mit wg_ip + api_token_ref + Kapazität. B spricht diesen Host per Proxmox-API an.
Entscheidungen (fix, aus Brainstorming)
- Ausgangszustand: frisches Debian + Root-SSH (kein Bare-Metal-PXE, kein vorinstalliertes Proxmox).
- Root-Zugang: einmaliges Root-Passwort im Formular. A deployt sofort CluPilots SSH-Key, danach key-only. Passwort wird nie dauerhaft gespeichert (verschlüsselt im Run-Context, nach
EstablishSshTrustgescrubbt). - Topologie: Standalone-Flotte (Laufzeit).
hosts.datacenter+ nullablehosts.cluster. Cluster-pro-RZ ist Wachstumsziel (v1.1+), Modell verbaut es nicht. - Management-Netz: CluPilot-VM = WireGuard-Hub. A legt Peer auf dem Hub an, richtet WG auf dem Host ein, Proxmox-API läuft nur über
wg_ip:8006(Port nie öffentlich). - Tests: voll gemockt (SSH/WG/Proxmox als Fake-Doubles), konsistent mit B. Echter End-to-End-Lauf gegen einen realen Server ist ein späterer, separater Schritt.
- Architektur: Ansatz ① — gemeinsamer DB-State-Machine + Tick-Orchestrator-Kern (kein Monolith-Job, kein Ansible). A ist
pipeline=hostauf demselben Kern, den B erbt.
Explizit NICHT in A v1.0 (YAGNI)
- Hetzner-Robot-Bestellautomatik (Server manuell bereitgestellt).
- Bare-Metal-Autoinstall (IPMI/PXE/ISO).
- Echte corosync-Cluster-Bildung, Live-Migration, HA, Rebalancing.
- Automatisches Wipe/Reinstall eines Hosts. Entfernen = nur Deregistrieren in CluPilot (physischer Server bleibt unangetastet).
- ZFS/Ceph-Storage-Setup (v1.0 nutzt die von der Debian-Installation gelieferte lokale Storage; ZFS-für-Replikation ist v1.1, ggf. Disk-Layout beim Server-Bestellen).
2. Architektur-Überblick
Admin-UI (Livewire, /admin/hosts)
│ "Host hinzufügen" → Host(status=pending) + ProvisioningRun(pipeline=host)
▼
Orchestrator-Kern (gemeinsam mit B)
ProvisioningRun ──(Tick/Sofort-Dispatch)──▶ AdvanceRunJob ──▶ RunRunner
│ Per-Run-Lock, Step-Auflösung, StepResult (advance/retry/fail), Timeout
▼
Host-Pipeline: 11 idempotente Step-Klassen (App\Provisioning\Steps\Host\*)
│ nutzen RemoteShell (SSH), WireguardHub, ProxmoxClient (Read)
▼
provisioning_step_events (append-only) ──Reverb──▶ Live-Stepper (Admin)
run_resources (externe IDs) · hosts (Ergebnis: active + Kapazität)
Isolationsgrenzen (jede Unit eine Verantwortung):
- Orchestrator-Kern weiß nichts über Hosts/Proxmox — nur über Runs, Steps, Results, Events. Wiederverwendbar für B.
- Step-Klassen kennen nur ihren einen Schritt + die Service-Interfaces (RemoteShell/WireguardHub/ProxmoxClient), nie einander.
- Services (RemoteShell, WireguardHub, ProxmoxClient) kapseln I/O hinter Interfaces → in Tests durch Fakes ersetzbar.
3. Datenmodell (Migrations + Models)
Gemeinsames Fundament (A baut es, B erbt es). Reihenfolge: runs/events/resources → hosts.
hosts (R11: UUID-adressierbar)
| Feld | Typ | Zweck |
|---|---|---|
id |
bigint PK | intern |
uuid |
uuid, unique | URL/Adressierung |
name |
string | z. B. pve-fsn-4 |
datacenter |
string | fsn, hel … (Placement-Filter) |
cluster |
string, nullable | v1.0 immer null (Wachstumsziel) |
public_ip |
string | Ersteinwahl per SSH |
wg_ip |
string, nullable | Mgmt-Adresse (Proxmox-API) |
wg_pubkey |
string, nullable | WG-Peer-Key des Hosts |
ssh_host_key |
text, nullable | Fingerprint-Pinning (known_hosts) |
api_token_ref |
text, nullable, encrypted | automation@pve!<id>=<secret> |
total_gb |
int, nullable | Storage-Kapazität (nach RegisterCapacity) |
total_ram_mb |
int, nullable | RAM |
cpu_cores |
int, nullable | Kerne |
cpu_weight |
int, nullable | relative Platzierungs-Gewichtung |
reserve_pct |
int, default 15 | Kapazitäts-Reserve (Placement-Abzug) |
pve_version |
string, nullable | dokumentarisch |
status |
string | pending, onboarding, active, error, disabled |
last_seen_at |
timestamp, nullable | letzter API-Kontakt |
| timestamps |
Kapazität = berechnet (v1.0 keine Instanzen → frei = total_gb − reserve). Placement (B) filtert später auf datacenter + status='active' + freie Zusage; cluster ignoriert die Query in v1.0.
provisioning_runs (polymorph, für A und B)
id, uuid, subject_type, subject_id, pipeline (string), current_step (int), status, attempt, max_attempts, next_attempt_at, started_at, finished_at, error (text null), context (json), timestamps
status:pending → running ⇄ waiting → completed, pluspaused(Admin),failed(Retries erschöpft/fatal).subject= Host (A) bzw. Order/Customer (B).context: json (bei A z. B. verschlüsseltes Root-Passwort transient, WG-Zuweisung, Marker).
provisioning_step_events (append-only)
id, run_id, step (string), attempt, outcome (advanced|retry|failed|info), message, external_ref (null), created_at — kein updated_at.
run_resources (Idempotenz-Brotkrumen)
id, run_id, host_id (null), kind, external_id, created_at — z. B. kind=wg_peer, kind=pve_token.
4. Orchestrator-Kern (Herzstück, test-first)
Interfaces / Werte
interface ProvisioningStep {
public function key(): string; // stabiler Step-Name für Events/Idempotenz
public function label(): string; // lokalisierbarer Anzeigename (i18n-Key)
public function maxDuration(): int; // Sekunden bis Timeout
public function execute(ProvisioningRun $run): StepResult;
}
final class StepResult {
// ::advance() | ::retry(int $afterSeconds, string $reason) | ::fail(string $reason)
}
PipelineRegistry
Map pipeline-name → [StepClass, …]. host → die 11 Host-Steps. customer → (B, später). Aufgelöst via Container.
RunRunner (eine Advance-Ausführung)
Cache::lock("run:{uuid}")(nie zwei Worker auf einem Run).- Aktuellen Step auflösen (
current_step→ Klasse). - Timeout-Check:
started_at + step.maxDurationüberschritten → als Fehler behandeln (Step entscheidet Retry/Fail-Politik über Attempt-Zählung). execute($run)→StepResult.- Event in
provisioning_step_eventsschreiben (append-only) + Reverb broadcast. - Result anwenden:
- advance:
current_step++; letzter Step →status=completed,finished_at. Sonst Sofort-DispatchAdvanceRunJob. - retry:
attempt++;attempt >= max_attempts→fail. Sonststatus=waiting,next_attempt_at = now + afterSeconds. - fail:
status=failed,errorsetzen.
- advance:
- Lock freigeben.
Runner-Pflichten (aus B-Handoff, gelten für A):
- Idempotenz-Check zuerst in jedem Step (externe ID / DB-State „schon getan?").
- Externe IDs sofort in
run_resourcesbevor weitergemacht wird.
Advancement
- AdvanceRunJob (Queue
provisioning) → ruft RunRunner. - Tick (
routes/console.phpSchedule, jede Minute, imscheduler-Service): Runsstatus IN (running,waiting) AND next_attempt_at <= now→AdvanceRunJobdispatchen. - Sofort-Dispatch bei Run-Anlage + nach jedem
advance.
5. Host-Pipeline — 11 Schritte
Namespace App\Provisioning\Steps\Host\. [E] = externe ID sofort persistieren, [P] = Polling, [F] = Fail (nicht retrybar).
- ValidateHostInput — Pflichtfelder (IP-Format,
datacenter, Root-Passwort im Context vorhanden), TCP:22 erreichbar. Ungültig →[F]. - EstablishSshTrust — Erst-Login mit Root-Passwort → CluPilots Public-Key in
/root/.ssh/authorized_keys, Host-Key erfassen (ssh_host_key[E]), Key-Login verifizieren. Danach Passwort auscontextscrubben. Idempotent: Key schon vorhanden + Key-Login ok → skip. - PrepareBaseSystem — FQDN/Hostname setzen,
/etc/hosts(FQDN → eigene IP, Proxmox-Pflicht),apt update, Basis-Pakete (curl gnupg ifupdown2 chrony). Idempotent. - ConfigureWireguard — Hub (CluPilot-VM):
wg_ipaus Mgmt-Subnetz vergeben, Peer (Host-Pubkey) hinzufügen, Hub reloaden. Host:wireguardinstallieren, Keypair erzeugen,wg0.confschreiben (Address=wg_ip, Peer=Hub-Pubkey+Endpoint, AllowedIPs), enable+start, Handshake verifizieren (Ping Hub-wg_ip).wg_ip+wg_pubkey→hosts[E],run_resources kind=wg_peer. Idempotent (Peer/Handshake vorhanden → skip). - InstallProxmoxVe — PVE-Repo (
bookworm pve-no-subscription) + GPG-Key, Enterprise-Repo deaktivieren,apt update,apt full-upgrade -y,apt install -y proxmox-ve postfix open-iscsi. Langläufer → großzügigesmaxDuration. Idempotent (proxmox-veinstalliert → skip). Proxmox-5xx/Netz = Retry; „Repo/Key ungültig" = Fail. - RebootIntoPveKernel — falls noch nicht rebootet (Marker in
context): Debian-Kernel entfernen (linux-image-amd64),update-grub, reboot auslösen, Marker setzen,retry(afterSeconds, "reboot"). Folge-Ausführungen[P]: SSH zurück +pveversionliefert PVE-Kernel (*-pve) → advance. Deadline großzügig; überschritten → Fail. Idempotent (schon auf PVE-Kernel → advance). - ConfigureProxmox —
vmbr0-Bridge sicherstellen (falls nicht vorhanden), Datacenter-Baseline-Firewall, No-Subscription-Nag entschärfen (optional), lokale VM-Storage bestätigen. Idempotent. - CreateAutomationToken —
automation@pve-User (PVE-Realm) + Minimal-Rolle (VM.Allocate/Config/PowerMgmt, Datastore.AllocateSpace, Sys.Audit …), API-Token erzeugen, Secret einmalig erfassen →hosts.api_token_refverschlüsselt[E],run_resources kind=pve_token. Idempotenz via Marker: existiert Token-Marker + persistiert → skip; Crash vor Persistenz → Token löschen+neu (Secret ist nur bei Erzeugung sichtbar). - VerifyProxmoxApi —
ProxmoxClientüberwg_ip:8006+ Token →GET /nodes→ erreichbar + autorisiert.[P](kurze Deadline). Fehlschlag → Retry, dann Fail. - RegisterCapacity — Node-Ressourcen via API (Kerne, RAM, Storage der VM-Storage) →
hosts.total_gb/total_ram_mb/cpu_cores/cpu_weight,pve_version,last_seen_at. Idempotent (überschreibt). - CompleteHostOnboarding —
hosts.status=active→ platzierbar. Abschluss-Event. Run →completed.
Reboot-Modellierung (der Knackpunkt): Der Reboot ist kein Sonderfall im Runner, sondern Step 6 gibt retry() zurück und pollt beim nächsten Tick, bis der Host im PVE-Kernel antwortet. Die State-Machine überlebt den Reboot per Definition — genau der Grund für Ansatz ①.
6. Services (I/O hinter Interfaces, Fakes in Tests)
App\Services\Ssh\RemoteShell(Interface):connectWithPassword,connectWithKey,run(cmd): CommandResult{exitCode,stdout,stderr},putFile,hostKeyFingerprint. ImplPhpseclibRemoteShell(phpseclib3). Test-DoubleFakeRemoteShell(scripted).App\Services\Wireguard\WireguardHub(Interface):allocateIp(): string,addPeer(pubkey, ip),removePeer(pubkey),reload. Impl schreibt Hub-Config auf der VM (Watch-Item: Schreibweg aus dem Container). Test-DoubleFakeWireguardHub.App\Services\Proxmox\ProxmoxClient— REST/api2/json, Token-AuthPVEAPIToken=automation@pve!<id>=<secret>, Basis aushosts.wg_ip. A v1.0 Read-Methoden:listNodes,nodeStatus,nodeStorage,createUserAndToken,createRole. (B erweitert umcloneVm,setCloudInit,guestExec,getTaskStatus…). Test-DoubleFakeProxmoxClient.
Secrets: Root-Passwort (transient, verschlüsselt, gescrubbt), CluPilot-SSH-Privatekey + WG-Hub-Privatekey aus .env/Config, api_token_ref Crypt-verschlüsselt. Nie in Git.
7. Admin-UI (R1/R2/R3/R5/R7/R9/R13/R16)
/admin/hosts(bestehendeAdmin\Hosts, Fixture → echt): Host-Liste mit Status-Pills (Farbe/Dots, keine Emoji R9), Datacenter, Kapazität (frei/total), „Host hinzufügen"-Button. Dunkles Token-Theme (bestehend).- Host hinzufügen (Formular; Anlegen ist nicht destruktiv → Full-page oder Panel, kein Modal-Zwang):
name,datacenter(Select),public_ip,root_password(Passwort-Feld, transient). Submit →Host(status=pending)+ProvisioningRun(pipeline=host, subject=Host, context={root_password: Crypt::encrypt(...)})+ Sofort-Dispatch. R11 UUID in URL. /admin/hosts/{uuid}(neu): Live-Stepper (x-ui.progress-stepper,state: pending|running|done|failed) anprovisioning_step_eventsgebunden, live via Reverb (privater, authentifizierter Admin-Kanal). Zeigt aktuellen Schritt, Event-Log, Kapazität (wenn active), Fehler + „Erneut versuchen" (setztstatus=running,next_attempt_at=now, Dispatch — idempotente Steps machen Retry sicher).- Host entfernen (destruktiv → wire-elements/modal-Bestätigung, R5): löscht nur den CluPilot-Record; kein Server-Wipe (klar kommuniziert).
Routen englisch (R13). Alle Texte DE+EN identische Keys (R16). Nur Token-Utilities (R3), keine Inline-Styles außer Progress-width (R4).
8. Fehler & Rollback
- Fehlgeschlagener Run →
status=failed, Hoststatus=error, Fehler im Panel sichtbar, manuelles „Erneut versuchen" (kein Auto-Reprovision). Idempotente Steps → Retry nimmt beim gescheiterten Schritt wieder auf, ohne Doppel-Ressourcen. - Kein automatisches Wipe. Ein halb-installierter Host bleibt physisch wie er ist; Admin kann retryen oder den Record entfernen und den Server manuell neu aufsetzen.
- Per-Run-Lock verhindert Doppelausführung; externe IDs vor Weitergabe persistiert → Crash-sicher (Test simuliert Crash nach Persistenz).
9. Tests (voll gemockt, TDD)
- Orchestrator-Kern:
StepResult; RunRunner advance/retry/fail/timeout/Lock;AdvanceRunJob; Tick-Query — mit 2–3 Fake-Steps. - Jeder Host-Step gegen
FakeRemoteShell/FakeWireguardHub/FakeProxmoxClient: Idempotenz (zweimal ausführen → keine Doppel-Ressource), externe-ID-Persistenz vor advance, Fail-vs-Retry-Klassifizierung. - RebootIntoPveKernel: Host-nicht-zurück → retry; Host-zurück-auf-PVE → advance.
- EstablishSshTrust: Passwort nach Erfolg aus
contextentfernt. - Admin: Gate (Gast→login, Nicht-Admin→403, Admin→200); „Host hinzufügen" legt Host+Run an und dispatcht; Liste rendert echte Daten; Retry-Action; Entfernen-Bestätigung.
- R12 Browser (0 Konsolenfehler):
/admin/hosts+/admin/hosts/{uuid}. - R15 Codex-Review clean vor „fertig". Keine Secrets im Repo.
10. Build-Reihenfolge (jede Phase grün)
- Migrations + Models (
hosts,provisioning_runs,provisioning_step_events,run_resources). - Orchestrator-Kern test-first (
StepResult,ProvisioningStep,PipelineRegistry,RunRunner,AdvanceRunJob, Tick) mit Fake-Steps. - Service-Interfaces + Fakes (
RemoteShell,WireguardHub,ProxmoxClient) + reale Impl (phpseclib/HTTP), Impl-Details gegen Fakes gespiegelt. - Host-Steps 1–4 (Validate, SshTrust, PrepareBase, Wireguard).
- Host-Steps 5–7 (InstallPve, Reboot, ConfigureProxmox).
- Host-Steps 8–11 (Token, VerifyApi, RegisterCapacity, Complete).
- Admin-UI: Hosts-Liste echt + „Host hinzufügen" + Host-Detail-Stepper + Reverb + Retry/Entfernen.
- Lokalisierung DE+EN, R12 Browser, R15 Codex, Commit(s).
11. Watch-Items
- WG-Hub-Schreibweg aus dem Container: App läuft im Docker-
app-Container, der WG-Hub auf der VM. Realen Schreib-/Reload-Weg festlegen (gemounteter Config-Pfad +wg syncconf, oder Hub-Service). v1.0 hinterWireguardHubabstrahiert; reale Verdrahtung markiert. - Debian-Kernel-Entfernung darf den Host nicht bricken — dokumentierte Befehlssequenz, echte Verifikation nötig (mocked baut die Sequenz nach).
- Reboot-Polling-Deadlines großzügig (Reboot + Kernelwechsel).
apt full-upgrade-Dauer → großzügiges Step-Timeout.- Token-Idempotenz: Secret nur bei Erzeugung sichtbar → Crash-vor-Persistenz muss Token löschen+neu.
- Regeln-Gotchas (State-Handoff §6):
@js()in Blade-Komponenten, Test-Isolation (sqlite:memory:), CSRF in Tests,@verbatim, Locale, Vite-Reload. - Langlaufende Schritte:
InstallProxmoxVeblockiert einen Worker bis zu 1800 s synchron. v1.0 löst das über eine dedizierteprovisioning-Queue (eigener Worker,--timeout=2100,retry_after=2400, Run-Lock 2100 s). v1.1-Verbesserung: lange Kommandos in resumierbare Teil-Jobs zerlegen statt synchron zu blockieren.
12. Definition of Done (A v1.0)
- „Host hinzufügen" erzeugt Host+Run, der ohne manuelles Zutun (gemockt) bis
completed/activeläuft. - Jeder Step idempotent: künstlicher Abbruch + erneuter Tick → keine Doppel-Ressourcen (
run_resourcesverifiziert). - Reboot-Step überlebt (Poll bis PVE-Kernel), Retry+Backoff+Timeout+Per-Run-Lock getestet.
- Externe IDs (wg_peer, pve_token) vor Weitergabe persistiert (Crash-Test).
- Root-Passwort nach SshTrust aus Context entfernt;
api_token_refverschlüsselt; keine Secrets im Repo. - Admin-Liste + Host-Detail live (Reverb); Retry + Entfernen funktionieren.
datacentergesetzt,clusternullable/ungenutzt.- Pest grün; R12 (0 Konsolenfehler); R15 Codex clean; DE+EN vollständig.