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

16 KiB
Raw Blame History

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.phppipelines; PipelineRegistry als Singleton im AppServiceProvider.

1. Neue Modelle / Migrations

Alle mit HasUuid wo in URLs adressierbar (R11).

  • customersname, 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).
  • instancescustomer_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_recordsinstance_id, provider, record_id, fqdn, type, value.
  • backupsinstance_id, external_job_id, schedule, last_ok_at?, status.
  • monitoring_targetsinstance_id, external_id, url, status.
  • onboarding_tasksinstance_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): stringguestExec + Exit-Code prüfen, wirft bei ≠0 (Runner macht daraus retry).

RunResource-kinds (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):

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.