# 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:<what>), `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 <pre-B commit>` 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.
