CluPilotCloud/docs/specs/2026-07-25-engine-customer-...

158 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 12 (reine DB/Placement — früh testbar, `datacenter`-Filter, no_capacity).
5. Steps 36 (Klon/CloudInit/Start/GuestAgent) gegen FakeProxmox; **[E]-Persistenz + Idempotenz je Step** testen (Crash nach recordResource simulieren → kein Doppel).
6. Steps 710 (Firewall/Deploy/Nextcloud/Admin) via guestExec-Fake; Secret-Handling (nie plaintext) testen.
7. Step 11 (Hetzner-DNS-Fake + TraefikWriter-Fake + Cert-Poll).
8. Steps 1213, dann 1415.
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.