From b9742f61eec01c74ca9dbab2a9e784da666a22b8 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 11:35:28 +0200 Subject: [PATCH] docs: customer pipeline (Subsystem B) implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-25-customer-pipeline.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-25-customer-pipeline.md diff --git a/docs/superpowers/plans/2026-07-25-customer-pipeline.md b/docs/superpowers/plans/2026-07-25-customer-pipeline.md new file mode 100644 index 0000000..2f01331 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-customer-pipeline.md @@ -0,0 +1,109 @@ +# Customer Provisioning Pipeline (Subsystem B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development per task. Contract of record: `docs/specs/2026-07-25-engine-customer-pipeline.md` (the 15-step Section 3) + `docs/handoffs/2026-07-25-clupilot-engine-v1.0-handoff.md` (roadmap). This plan maps them to ordered TDD tasks + exact signatures. + +**Goal:** A paid Stripe order provisions a running, secured, monitored Nextcloud instance automatically, on the shared orchestrator core built for Subsystem A. + +**Architecture:** `pipeline='customer'` (subject = Order) on the existing DB-state-machine + tick orchestrator. 15 idempotent `CustomerStep` classes, VM lifecycle via the extended `ProxmoxClient` (API + guest-agent), DNS via Hetzner, TLS via Traefik/HTTP-01. Fully mocked in tests via the Fakes. + +**Tech Stack:** Laravel 13.8, Livewire 3, Reverb, Redis provisioning queue, Pest 4 (sqlite `:memory:`), Stripe (test), phpseclib (present). + +## Global Constraints + +- R1/R2 class-based full-page Livewire · R3 token utilities only · R5 destructive→modal · R7 responsive · R8 container · R9 no emoji · R11 UUID URLs · R12 browser 0 console errors · R13 English routes · R14 self-hosted fonts · R15 Codex clean · R16 DE+EN identical keys · R17 blade `@php` rules. +- Tests: sqlite `:memory:` (phpunit force). Secrets never in git/events (`external_ref` never a secret); generated admin/DB passwords `forgetContext` + never plaintext-persisted. +- **Reuse the core, do NOT rebuild** (spec §0): StepResult (advance/retry/**poll**/fail), ProvisioningStep, RunRunner, ProvisioningRun/RunResource/StepEvent, PipelineRegistry, RemoteShell/WireguardHub, read-only ProxmoxClient. +- Test cmd: `docker compose run --rm --no-deps -u 1000:1000 -e HOME=/tmp app ./vendor/bin/pest` + +## File Structure + +**Models/Migrations:** `Customer, Order, Instance, DnsRecord, Backup, MonitoringTarget, OnboardingTask` (+ 7 migrations). Order implements `App\Provisioning\Contracts\ProvisioningSubject`. + +**Steps:** `app/Provisioning/Steps/Customer/CustomerStep.php` (base) + 15 step classes (spec §3 table). + +**Shared:** pull `recordResource()/hasResource()` into a `App\Provisioning\Steps\ManagesRunResources` trait used by both `HostStep` and `CustomerStep`. + +**Services:** extend `App\Services\Proxmox\{ProxmoxClient,FakeProxmoxClient,HttpProxmoxClient}`; new `App\Services\Dns\{HetznerDnsClient(interface),HttpHetznerDnsClient,FakeHetznerDnsClient}`; new `App\Services\Traefik\{TraefikWriter(interface),SshTraefikWriter,FakeTraefikWriter}`. + +**Intake:** `app/Http/Controllers/StripeWebhookController.php` + route (POST, no CSRF), `app/Actions/StartCustomerProvisioning.php`. + +**UI:** rebind `app/Livewire/Admin/Provisioning.php`; customer dashboard stepper (`app/Livewire/Dashboard.php`); extend `StepAdvanced::broadcastOn()` + `routes/channels.php` (`customer.{id}.run`). `lang/{de,en}/provisioning.php`. + +**Config:** `config/provisioning.php` → `plans`, `pipelines.customer`, `dns`, `traefik`. + +## Interfaces (verbatim — consumed across tasks) + +```php +// ProxmoxClient additions (spec §4) +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'=>...,'exitstatus'=>'OK'|...] +public function applyFirewall(string $node, int $vmid, array $rules): void; + +// App\Services\Dns\HetznerDnsClient +public function upsertRecord(string $fqdn, string $type, string $value): string; // record_id +public function deleteRecord(string $recordId): void; + +// App\Services\Traefik\TraefikWriter +public function write(string $subdomain, string $targetHost): void; +public function remove(string $subdomain): void; +public function certReachable(string $fqdn): bool; +``` + +**CustomerStep base helpers:** `order(run):Order`, `instance(run):Instance` (via `context('instance_id')`), `plan(run):array` (`config('provisioning.plans.'.$order->plan)`), `guest(ProxmoxClient,Instance,string $cmd):string` (guestExec, throws on exit≠0). RunResource kinds: `instance_id, vmid, dns_record_id, nc_admin, backup_job_id, monitoring_target_id`. Context keys: `instance_id, host_id, node, vmid, subdomain, plan`. + +## Tasks (ordered — spec §9 build order) + +### Task 1: Models + migrations (Phase 1) +Create 7 migrations + 7 models. Order `implements ProvisioningSubject` (`onProvisioningFailed`→status `failed`). Casts (uuid where URL-addressable; encrypted `nc_admin_ref` only stores username/ref never password; booleans). `subdomain` unique, `orders.stripe_event_id` unique. Capacity: add `Host::committedGb()` / free calc filtered on datacenter+active. +- [ ] Test: create order→run subject resolves; unique stripe_event_id enforced; instance subdomain unique; freeGb minus committed quotas. → implement → green → commit. + +### Task 2: ManagesRunResources trait + CustomerStep base + config (Phase 2) +Extract `recordResource/hasResource` into trait; `HostStep` + `CustomerStep` use it. `CustomerStep` helpers above. `config/provisioning.php`: `plans` (start/team/business/enterprise with quota/disk/ram/cores/template_vmid), `pipelines.customer` (15 classes — textual, built incrementally), `dns`, `traefik`. +- [ ] Test: trait keeps HostStep tests green; CustomerStep resolves order/instance/plan. → implement → green → commit. + +### Task 3: ProxmoxClient extension (Phase 3) +Add the §4 methods to interface + `FakeProxmoxClient` (deterministic: nextVmid counter, cloneVm→UPID string, taskStatus→stopped/OK, vmStatus→running after start, guestAgentPing→true, guestExec→exit 0 configurable-fail, applyFirewall records) + `HttpProxmoxClient` (real endpoints, UPID poll via `/tasks/{upid}/status`). +- [ ] Test (Fake): clone→vmid+upid; taskStatus OK; guestExec exit code; configurable failure. → implement → green → commit. + +### Task 4: Steps 1–2 (Phase 4) +`ValidateOrder` (paid? plan known? datacenter valid? → advance/[F]), `ReserveResources` (idempotent on existing instance; placement = first active host in datacenter with free committed ≥ quota; create instance; context instance_id/host_id/node; [E] instance_id; [F] no_capacity). +- [ ] Tests: validate fail on unpaid/unknown plan; reserve creates instance once (idempotent), picks datacenter host, no_capacity fails. → green → commit. + +### Task 5: Steps 3–6 (Phase 5) — against FakeProxmox +`CloneVirtualMachine` (idempotent on vmid; **[E] vmid before poll**; poll taskStatus; [F] template_missing/clone_failed), `ConfigureCloudInit` (setCloudInit+resizeDisk idempotent), `StartVirtualMachine` (idempotent on running; poll), `WaitForGuestAgent` (poll ping; own deadline→[F]). +- [ ] Tests each: [E] persistence before poll, idempotency (crash after recordResource→no dup vmid), poll vs advance, fail classification. → green → commit. + +### Task 6: Steps 7–10 (Phase 6) — via guestExec fake +`ConfigureNetwork` (guest verify + applyFirewall), `DeployApplicationStack` (write compose .env with generated DB pw → forgetContext; compose up; poll healthy), `ConfigureNextcloud` (occ trusted_domains/cron/install-guard idempotent), `CreateCustomerAdmin` (idempotent on nc_admin; generate pw via OC_PASS env; [E] nc_admin=username; **never persist password**). +- [ ] Tests: secrets never plaintext-persisted (context scrubbed, events carry no secret); idempotency; admin created once. → green → commit. + +### Task 7: Step 11 + DNS/Traefik services (Phase 7) +`HetznerDnsClient` (interface+Fake+Http), `TraefikWriter` (interface+Fake+SSH). `ConfigureDnsAndTls`: upsert DNS **[E] dns_record_id** + dns_records row; Traefik write + route_written; **[P] certReachable** → cert_ok; [F] cert_timeout / invalid domain; DNS 5xx→retry. +- [ ] Tests: dns record recorded before advance; cert poll→advance; timeout→fail. → green → commit. + +### Task 8: Steps 12–15 + e2e (Phase 8) +`RegisterBackup` ([E] backup_job_id), `RegisterMonitoring` ([E] monitoring_target_id), `RunAcceptanceChecks` (gate: cert/status.php/admin-login/backup/monitoring; [F] acceptance_failed:), `CompleteProvisioning` (instance+order active, onboarding_tasks, notify; last step→completed). **Mocked e2e:** paid order → drive runner → completed, instance active, resources each once, no secret leaks, customer access only after 14. +- [ ] Tests + e2e + crash-idempotency. → green → commit. + +### Task 9: Stripe webhook (Phase 9) +`StripeWebhookController` (verify signature in non-test; test-mode path), idempotent on `orders.stripe_event_id` unique → create customer+order+run+dispatch. `StartCustomerProvisioning` action (transaction, dispatch after commit). +- [ ] Tests: duplicate event → one run; paid event → run created + AdvanceRunJob pushed. → green → commit. + +### Task 10: Live progress (Phase 10) +Rebind `Admin\Provisioning` to real runs+events (live via `admin.runs`). Customer dashboard: bind stepper to logged-in customer's run; add `PrivateChannel('customer.{id}.run')` to `StepAdvanced::broadcastOn()` (+ channels.php authz: own customer only); onboarding_tasks feed. Access to cloud only when `active`. +- [ ] Tests: admin provisioning renders real runs; customer sees only own run; channel authz. → green → commit. + +### Task 11: Verify (Phase 11) +DE+EN `provisioning.step.*` (+ any UI keys) identical keys; full Pest green; R12 Puppeteer (admin provisioning + customer dashboard) 0 console errors; R15 Codex `--scope branch --base ` loop until clean; seeder demo order/run gated local/testing; commit + push (token inline). + +## Self-Review +- **Spec coverage:** §1 models→T1; §2 base→T2; §4 client→T3; §3 steps 1–15→T4–T8; §5 services→T7; §6 Stripe→T9; §7 live→T10; §8 config→T2; §9 order→T1–T11; §10 DoD→T8/T11; §11 watch-items (poll, recordResource-before-external, guest-agent, traefik path, secrets)→embedded in T5–T8. +- **Placeholder scan:** none — each task has concrete conditions + tests referencing the spec table. +- **Type consistency:** ProxmoxClient/HetznerDnsClient/TraefikWriter signatures fixed above and reused; RunResource kinds + context keys enumerated once.