# Engine-Design-Spec — Subsystem B: Kunden-Provisionierung (die 15 Schritte)

**Erstellt:** 2026-07-25 · **Status:** Bau-Vertrag für Subsystem B
**Ergänzt:** `docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md` (Fahrplan) — **dies ist die fehlende „Sektion 3"** (Detailverhalten), passgenau zum bereits gebauten Kern (Subsystem A).

Bau **test-first** (Superpowers `test-driven-development`), `ProxmoxClient`/`RemoteShell`/DNS in Step-Tests über die **Fakes** mocken. R15-Codex-Loop bis clean (der Host-Onboarding-Bau brauchte ~19 Runden — die Idempotenz-/Race-/External-ID-Disziplin unten ernst nehmen).

---

## 0. Was der Kern schon liefert (NICHT neu bauen)

- **Step-Contract** `App\Provisioning\Contracts\ProvisioningStep`: `key()`, `label()`, `maxDuration()`, `execute(ProvisioningRun): StepResult`.
- **`StepResult`**: `advance()` · `retry($afterSec,$reason)` (zählt aufs `max_attempts`-Budget → sonst `fail`) · **`poll($afterSec,$reason)`** (KEIN Budget — für lange Operationen; Schritt erzwingt eigene Deadline via `fail`) · `fail($reason)`.
- **`RunRunner`**: Per-Run-Lock (2100 s), Timeout via `maxDuration()` → automatisch `retry`, Backoff, Event-Record, Continuation-Dispatch **nach** Lock-Release. Fehlende Pipeline/Step = **fatal**.
- **`ProvisioningRun`**: polymorpher `subject`, `context` (json) mit `context($key,$default)` / `mergeContext([])` / `forgetContext($key)`, `current_step`, `attempt`/`max_attempts`, `next_attempt_at`, `started_at`.
- **`RunResource`** (Idempotenz-Brotkrumen): `firstOrCreate(['run_id','kind'], ['host_id','external_id'])` — **kind ist je Run eindeutig**, **VOR** dem Advance persistieren. Helper im `HostStep`: `recordResource()`, `hasResource()`.
- **`ProvisioningStepEvent`** (append-only): `run_id, step, attempt, outcome, message, external_ref`.
- **`StepAdvanced`** broadcastet bereits auf `PrivateChannel('admin.runs')` → Admin-Live-Fortschritt ist verdrahtbar; für Kunden zusätzlichen Kanal ergänzen (§7).
- **`ProxmoxClient`** (Interface) read-only (`forHost`, `listNodes`, `nodeStatus`, `nodeStorage`) + `HttpProxmoxClient` + `FakeProxmoxClient`. **B erweitert das Interface** (§4).
- **`RemoteShell`** (SSH, phpseclib + Fake), **`WireguardHub`**. Proxmox-Automation-Token hat bereits die nötigen Privs (`VM.Clone, VM.Config.*, VM.PowerMgmt, VM.GuestAgent.Unrestricted, Datastore.AllocateSpace` — s. `config/provisioning.php`).
- Registrierung: Pipelines in **`config/provisioning.php` → `pipelines`**; `PipelineRegistry` als Singleton im `AppServiceProvider`.

---

## 1. Neue Modelle / Migrations

Alle mit `HasUuid` wo in URLs adressierbar (**R11**).

- **`customers`** — `name, email, locale, stripe_customer_id?, status`.
- **`orders`** (= **Run-Subject**, implements `ProvisioningSubject`) — `customer_id`, `plan` (start|team|business|enterprise), `amount_cents`, `currency`, `datacenter` (Zielregion), **`stripe_event_id` UNIQUE** (Idempotenz), `status` (paid|provisioning|active|failed). `onProvisioningFailed()` → `status='failed'` (**kein Auto-Refund**).
- **`instances`** — `customer_id, order_id, host_id`, `vmid?`, `plan`, `quota_gb`, `disk_gb`, `ram_mb`, `cores`, `subdomain` UNIQUE, `custom_domain?`, `nc_admin_ref?` (nur Username/verschlüsselte Ref — **nie Passwort**), `route_written` bool, `cert_ok` bool, `status` (reserving|provisioning|active|suspended|failed). FKs.
- **`dns_records`** — `instance_id, provider, record_id, fqdn, type, value`.
- **`backups`** — `instance_id, external_job_id, schedule, last_ok_at?, status`.
- **`monitoring_targets`** — `instance_id, external_id, url, status`.
- **`onboarding_tasks`** — `instance_id, key, done bool, done_at?` (füttert den Kunden-Onboarding-Stepper aus dem Portal).

**Kapazität bleibt berechnet** (kein Ledger): freie Zusage = `hosts.total_gb − reserve(reserve_pct) − Σ instances.quota_gb (nicht failed)` — **gefiltert auf `datacenter` + `status='active'`** (der `Host` hat bereits `freeGb()`; ggf. dort einhängen). Cluster wird v1.0 ignoriert.

---

## 2. `CustomerStep` Basisklasse (analog `HostStep`)

`app/Provisioning/Steps/Customer/CustomerStep.php` — Helper:
- `label()` → `'provisioning.step.'.$this->key()`, Default `maxDuration()` = 300 (Schritte überschreiben).
- `order(ProvisioningRun): Order` (= `$run->subject`).
- `instance(ProvisioningRun): Instance` (über `context('instance_id')`).
- `plan(ProvisioningRun): array` (aus `config('provisioning.plans.'.$order->plan)`).
- `recordResource()/hasResource()` (aus `HostStep` hochziehen → gemeinsame Basis oder Trait).
- `guest(ProxmoxClient $pve, Instance $i, string $cmd): string` — `guestExec` + Exit-Code prüfen, wirft bei ≠0 (Runner macht daraus `retry`).

**RunResource-`kind`s (Kunden):** `instance_id, vmid, dns_record_id, nc_admin, backup_job_id, monitoring_target_id`.
**Context-Keys:** `instance_id, host_id, node, vmid, subdomain, plan`.

---

## 3. Die 15 Schritte (`App\Provisioning\Steps\Customer\`)

Registrieren als `'customer' => [...]` in `config/provisioning.php`. Legende: **[E]** externe ID sofort in `run_resources`, **[P]** `poll()`, **[F]** `fail()` (fatal).

| # | Key / Klasse | maxDuration | Kernverhalten |
|---|---|---|---|
| 1 | `validate_order` `ValidateOrder` | 60 | Order `paid`? Plan bekannt (`config.plans`)? Ziel-`datacenter` gültig? Read-only, idempotent. **[F]** `order_not_paid` / `unknown_plan`. → advance. |
| 2 | `reserve_resources` `ReserveResources` | 60 | **Idempotenz:** existiert `instance` zu `order_id` → skip. Sonst **Placement** (§1) → Host wählen. `instance`-Row anlegen (`status=reserving`, quota/disk/ram/cores aus Plan, `subdomain` = eindeutig aus Kundenname+rand). `context: instance_id, host_id, node`. **[E]** kind=`instance_id`. **[F]** `no_capacity` (Operator-Eingriff; kein endloses Retry). → advance. |
| 3 | `clone_vm` `CloneVirtualMachine` | 900 | **Idempotenz:** `hasResource('vmid')` → direkt zum Poll. Sonst `vmid = pve.nextVmid()`, `upid = pve.cloneVm(node, plan.template_vmid, vmid, name)`. **[E] kind=`vmid` SOFORT** (vor Poll), `context.vmid`. Dann **[P]** `pve.taskStatus(node,upid)`: running→`poll(10)`, ok→advance, error→**[F]** `clone_failed`. **[F]** `template_missing`. |
| 4 | `configure_cloud_init` `ConfigureCloudInit` | 120 | `pve.setCloudInit(node,vmid,{ipconfig0, ciuser, sshkeys?, nameserver})` + `pve.resizeDisk(node,vmid,'scsi0','+'.(disk_gb).'G')` (nur wenn kleiner). Idempotent (set/resize prüfen Ziel). → advance. **[F]** ungültige Cloud-Init-Config. |
| 5 | `start_vm` `StartVirtualMachine` | 180 | **Idempotenz:** `pve.vmStatus(node,vmid).status==running` → advance. Sonst `upid=pve.startVm` → **[P]** taskStatus. Fehler→**[F]** `start_failed`. |
| 6 | `wait_for_guest_agent` `WaitForGuestAgent` | 300 | **[P]** `pve.guestAgentPing(node,vmid)` bis Antwort → advance; eigene Deadline via `started_at`+270s → **[F]** `guest_agent_timeout`. |
| 7 | `configure_network` `ConfigureNetwork` | 120 | Via `guest()`: Netz/Hostname im Gast verifizieren (falls nicht rein cloud-init). `pve.applyFirewall(node,vmid, allow 80/443, deny Mgmt)`. Idempotent. → advance. |
| 8 | `deploy_application_stack` `DeployApplicationStack` | 1200 | Via `guest()`: Compose-`.env` schreiben (DB-Passwörter generieren, **nicht** in Run-Context lassen → nach Gebrauch `forgetContext`), `docker compose up -d` (Nextcloud+Collabora+MariaDB — Blueprint hat Docker). **[P]** bis Container healthy (`docker compose ps`/occ status) → advance; Fehler→`retry` (Backoff), nach Budget `fail`. |
| 9 | `configure_nextcloud` `ConfigureNextcloud` | 300 | Via `guest()` `occ`: trusted_domains (subdomain [+custom_domain]), Cron→`background:cron`, `maintenance:install`-Idempotenz-Guard (nur wenn nicht installiert), Default-Apps, `config:system:set`. Idempotent. → advance. |
| 10 | `create_customer_admin` `CreateCustomerAdmin` | 120 | **Idempotenz:** `hasResource('nc_admin')` → skip. Sonst Initial-Passwort **generieren**, `occ user:add` (via `OC_PASS`-Env, nicht Argv). **Passwort verschlüsselt an Kunde** (Notification/Mail), **nie plaintext persistieren**; nur Username → `instance.nc_admin_ref`, **[E] kind=`nc_admin`** (external_id=Username). → advance. |
| 11 | `configure_dns_and_tls` `ConfigureDnsAndTls` | 900 | **DNS:** `HetznerDnsClient.upsertRecord(subdomain → host.public_ip)` → **[E] kind=`dns_record_id` sofort**, `dns_records`-Row. **Traefik:** `TraefikWriter.write(subdomain → guest)` (File-Provider-YAML), `instance.route_written=true`. **TLS:** **[P]** `certReachable(fqdn)` (HTTP-01; Port 80 muss offen sein, DNS propagieren) bis Cert da → `instance.cert_ok=true`, advance; großzügige Deadline → **[F]** `cert_timeout`. DNS-API-5xx=`retry`, ungültige Domain=**[F]**. |
| 12 | `register_backup` `RegisterBackup` | 120 | Backup-Job anlegen (vzdump-Schedule/externer Dienst). **[E] kind=`backup_job_id`**, `backups`-Row. Idempotent. → advance. |
| 13 | `register_monitoring` `RegisterMonitoring` | 120 | Instanz als Monitoring-/Uptime-Target registrieren. **[E] kind=`monitoring_target_id`**, `monitoring_targets`-Row. Idempotent. → advance. |
| 14 | `run_acceptance_checks` `RunAcceptanceChecks` | 180 | **Gate.** Alles prüfen: HTTPS/Cert erreichbar, Nextcloud-`status.php` healthy, Admin-Login klappt, Backup-Job existiert, Monitoring grün. Ein Fehler → **[F]** `acceptance_failed:<was>` (manueller Retry). **Kein** Kundenzugang vor Bestehen. → advance nur wenn alles grün. |
| 15 | `complete_provisioning` `CompleteProvisioning` | 60 | `instance.status=active`, `order.status=active`, `onboarding_tasks` anlegen, „Ihre Cloud ist bereit"-Notification mit Zugang. **Erst hier** Kundenzugang. → advance (letzter Schritt → Run `completed`). |

**Harte Regeln (aus Handoff):** Kundenzugang ERST nach 14. Backup(12)+Monitoring(13) sind Teil der Bereitstellung. Zahlung ok trotz Fehler → `failed` + manueller Retry, kein Auto-Refund. occ/Compose via **guest-agent**, nicht SSH-Warten. DNS via Hetzner **[E]**, Cert **HTTP-01** (kein DNS-01).

---

## 4. `ProxmoxClient`-Erweiterung (Interface + Http + Fake)

Zum Interface hinzufügen (der Kommentar dort kündigt es an):
```php
public function nextVmid(): int;
public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string; // UPID
public function setCloudInit(string $node, int $vmid, array $params): void;
public function resizeDisk(string $node, int $vmid, string $disk, string $size): void;
public function startVm(string $node, int $vmid): string;   // UPID
public function vmStatus(string $node, int $vmid): array;    // ['status'=>'running'|'stopped', …]
public function guestAgentPing(string $node, int $vmid): bool;
public function guestExec(string $node, int $vmid, string $command): array; // ['exitcode'=>int,'out-data'=>string]
public function taskStatus(string $node, string $upid): array; // ['status'=>'running'|'stopped','exitstatus'=>'OK'|…]
public function applyFirewall(string $node, int $vmid, array $rules): void;
```
**Fake** deterministisch (Task sofort „stopped/OK", guestExec exit 0, konfigurierbare Fehlerfälle für Tests). **Http** gegen `/api2/json/nodes/{node}/qemu/...` (UPID-Polling über `/tasks/{upid}/status`).

## 5. Neue Services

- **`App\Services\Dns\HetznerDnsClient`** (Interface + Http + Fake): `upsertRecord(fqdn, type, value): string /*record_id*/`, `deleteRecord(record_id)`. Token verschlüsselt/aus `.env`.
- **`App\Services\Traefik\TraefikWriter`** (Interface + Fake): schreibt/entfernt die File-Provider-YAML (Router subdomain→Gast). **Schreibweg festlegen** (Watch-Item): via Host-SSH in ein gemountetes Verzeichnis, wo Traefik läuft. `certReachable(fqdn): bool` (HTTP-01-Poll) hier oder eigener `CertPoller`.

## 6. Bestell-Eingang (Stripe)

- Webhook-Controller (Signatur prüfen, Test-Modus). Auf `checkout.session.completed`/`payment_intent.succeeded`: **Idempotenz über `orders.stripe_event_id` UNIQUE** → doppelte Webhooks erzeugen keinen 2. Run.
- Order (`paid`) + `customer` anlegen → `ProvisioningRun` (`pipeline='customer'`, `subject=Order`, `max_attempts` sinnvoll) → `AdvanceRunJob::dispatch($run->uuid)` (sofort, nicht auf Tick warten).
- Kein Live-Stripe/echte Zahlungen in Dev.

## 7. Live-Fortschritt (an bestehende UI binden)

- **Admin `/admin/provisioning`** (aktuell Fixtures, `app/Livewire/Admin/Provisioning.php`) → echte `provisioning_runs` + `provisioning_step_events`. `StepAdvanced` broadcastet bereits auf `admin.runs` → Livewire via Echo aktualisieren (Kanal in `routes/channels.php` autorisieren, admin-only).
- **Kundendashboard** (`/dashboard`, aktuell Fixture-Stepper) → an den Run **des eingeloggten Kunden** binden; zusätzlichen `PrivateChannel('customer.'.$customerId.'.run')` in `StepAdvanced::broadcastOn()` ergänzen (pro-Kunde scopen, nur eigener Run). `onboarding_tasks` speisen den Stepper. Zugang zur Cloud erst nach `active`.
- Panel = reine View auf die Tabellen.

## 8. Config-Ergänzungen (`config/provisioning.php`)

- `'pipelines' => ['customer' => [ Customer\ValidateOrder::class, … Customer\CompleteProvisioning::class ]]`.
- `'plans' => ['start'=>['quota_gb'=>100,'disk_gb'=>120,'ram_mb'=>4096,'cores'=>2,'template_vmid'=>9000], 'team'=>[…500…], 'business'=>[…1000…], 'enterprise'=>[…]]` (Template-VMID je Plan/Blueprint-Version).
- `'dns' => ['provider'=>'hetzner','token'=>env('HETZNER_DNS_TOKEN'),'zone'=>'clupilot.com']`.
- `'traefik' => ['dynamic_path'=>env('TRAEFIK_DYNAMIC_PATH')]`.

## 9. Build-Reihenfolge (jede Phase grün)

1. Migrations+Models (customers, orders, instances, dns_records, backups, monitoring_targets, onboarding_tasks). Order⇒`ProvisioningSubject`.
2. `CustomerStep`-Basis + `plans`/`customer`-Pipeline-Config (leere/`fail`-Stubs zum Registrieren).
3. `ProxmoxClient`-Erweiterung + Fake/Http.
4. Steps 1–2 (reine DB/Placement — früh testbar, `datacenter`-Filter, no_capacity).
5. Steps 3–6 (Klon/CloudInit/Start/GuestAgent) gegen FakeProxmox; **[E]-Persistenz + Idempotenz je Step** testen (Crash nach recordResource simulieren → kein Doppel).
6. Steps 7–10 (Firewall/Deploy/Nextcloud/Admin) via guestExec-Fake; Secret-Handling (nie plaintext) testen.
7. Step 11 (Hetzner-DNS-Fake + TraefikWriter-Fake + Cert-Poll).
8. Steps 12–13, dann 14–15.
9. Stripe-Webhook + Idempotenz + Run-Anlage.
10. Live-Fortschritt Admin+Kunde (Reverb).
11. E2E gegen Dev-Host+Template falls verfügbar, sonst voll gemockt grün.

## 10. Definition of Done (B, v1.0)

- [ ] Bezahlte Test-Order → Run läuft ohne Zutun bis `completed` (voll gemockt grün; echt gegen Dev-Host+Template wenn verfügbar).
- [ ] Jeder Step idempotent: Abbruch nach `recordResource` + erneuter Tick → **keine** Doppel-Ressourcen (über `run_resources` verifiziert).
- [ ] `poll` vs `retry` korrekt genutzt (lange Ops zehren nicht am Retry-Budget; eigene Deadlines via `fail`).
- [ ] Externe IDs (vmid/dns/backup/monitoring) **vor** Weitergabe persistiert (Crash-danach-Test).
- [ ] Admin-Passwort/DB-Secrets nie plaintext persistiert (`forgetContext`, verschlüsselt).
- [ ] Kundenzugang erst nach Acceptance (14).
- [ ] Stripe-Doppel-Webhook → nur EINE Provisionierung.
- [ ] Placement respektiert `datacenter`+freie Zusage; `cluster` bleibt `null`/ungenutzt.
- [ ] Admin+Kunden-Fortschritt live (Reverb).
- [ ] Pest grün; R12 (Admin/Kunden-Views 0 Konsolenfehler); R15 Codex clean; keine Secrets im Repo.

## 11. Watch-Items

1. **`poll()` nutzen** für Klon/Start/GuestAgent/Deploy/Cert — sonst laufen dir die `max_attempts` weg. Eigene Deadline im Step (via `started_at`) für Poll-Schritte.
2. **`recordResource` VOR jeder externen Weitergabe** (vmid vor Poll!). Sonst hinterlässt ein Crash Waisen ohne Brotkrumen.
3. **Guest-Agent statt SSH** — Blueprint-Template muss guest-agent + docker enthalten (Voraussetzung, Handoff §5).
4. **Traefik-Schreibweg** konkret festlegen (wo läuft Traefik, wie erreicht die YAML es). HTTP-01 braucht Port 80 + DNS-Propagation → Cert-Poll-Deadline großzügig.
5. **Secrets** (Hetzner-DNS-Token, Proxmox-Token, generierte Admin-/DB-Passwörter) verschlüsselt/`forgetContext`, nie in Git/Events (`external_ref` nie Secret).
6. Kern-Gotchas (State-Handoff §6): `@js()`-in-Komponenten, Test-Isolation (sqlite), Locale-aware Formate.
