From 36965d24b428b7ac9e1173d1847f81c9b9232de3 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Tue, 30 Jun 2026 20:36:12 -0300 Subject: [PATCH] =?UTF-8?q?docs(specs):=20add=20specs=2026-29=20=E2=80=94?= =?UTF-8?q?=20the=20local-cloud=20platform=20lane=20(M9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designs the "complete devstack = local cloud" vision (4 specs, adversarially verified) + FEATURES #18-21 + a ROADMAP M9 lane (~14w, stays 0.x): - 26 CLI completeness & README reconciliation: the README is stale (up/down/ secrets/trust/dns/tunnel are SHIPPED, only shell/logs are stubs); land real shell, up --rebuild/--skip-clone, tunnel up/down, machine-wide workspace list + registry, reserve post-1.0 verbs. Ships first as v0.3.0. - 27 data-plane resource layer: generalize provision-on-demand into a Resource model + per-engine Provisioner family (internal/resource) + a declarative resources: block (the recommended path) + a new up-saga phase; free-text kind ledger (zero migrations), crypto/rand keygen, RemoveProvisioned op. - 28 cloud-engine templates: LocalStack + ministack (ministack.org AWS-emulation image, provides: aws, interchangeable with LocalStack) + NATS (JetStream) + Kafka (Redpanda default) + RabbitMQ. Native messaging default, LocalStack opt-in. - 29 imperative resource commands: db/s3/queue/stream/topic + thin `aws --` shim, each mirroring the provision-phase lock->overlay->provisioner->ledger flow. db+s3 ship now on the PG/MinIO substrate; messaging gated on spec 28. Owner decisions recorded: ministack = the ministack.org image (AWS-emulation engine, not a preset); native NATS/Redpanda default + LocalStack opt-in; declarative resources: is the recommended path; build spec 26 first then the substrate + db/s3. Adversarial verify caught + fixed spec 26 duplicating specs 16 (logs)/13 (purge-data)/05 (tunnel-secret-guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/FEATURES.md | 20 ++ docs/ROADMAP.md | 22 +++ docs/specs/26-cli-completeness.md | 157 +++++++++++++++ docs/specs/27-resource-layer.md | 200 ++++++++++++++++++++ docs/specs/28-cloud-engine-templates.md | 242 ++++++++++++++++++++++++ docs/specs/29-resource-commands.md | 167 ++++++++++++++++ 6 files changed, 808 insertions(+) create mode 100644 docs/specs/26-cli-completeness.md create mode 100644 docs/specs/27-resource-layer.md create mode 100644 docs/specs/28-cloud-engine-templates.md create mode 100644 docs/specs/29-resource-commands.md diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bf398b5..d573459 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -77,6 +77,22 @@ Guided `devstack init`: a Bubble Tea v2 TUI (left engine-picker + right live `wo **17. Release automation + 0.x conventional-commit versioning · 0.75w thin (+0.75w wizard) — the v0.2.0 gate.** ([spec 25](specs/25-release-automation.md)) Conventional commits on `main` → `svu next --v0` → tag + goreleaser **in one workflow** using the built-in `GITHUB_TOKEN` (no PAT/App token), gated by an owner-set `RELEASE_ENABLED` repo variable (default off = the kill-switch); a human-cut tag still releases via the same workflow. Fixes the load-bearing **ldflags v-prefix bug** (`{{.Version}}` stamps `0.1.0`, which `x/mod/semver` rejects) that currently makes the shipped spec-14 update-notifier + `self update` treat a released binary as a dev build and never offer updates. Adds a grouped goreleaser changelog, a PR-title conventional-commit lint, and a CI `v0.*` guard (stay 0.x: BREAKING → minor, never 1.0.0). Optional `devstack release` maintainer wizard. *Everything else in this lane ships through this pipeline — build it first.* +### Local-cloud platform lane (post-M8, v0.x beta; specs 26–29) + +Turns the shared-infra tool into a **local cloud**: cloud-emulation engines + a first-class data-plane resource layer + the verbs to drive it, plus a CLI/README honesty gate that lands first. Strictly additive over M0–M8; stays 0.x. It generalizes provision-on-demand (Postgres-only today) from one engine to a family. **Build order: #21 (reconcile) first → #20 (substrate) → #19a db/s3 in parallel with #18 (engines) → #19b messaging last.** + +**18. Local-cloud shared-engine templates · 2.5w.** ([spec 28](specs/28-cloud-engine-templates.md)) +Net-new warm, ref-counted, tenant-isolated cloud engines on `devstack_shared`: **LocalStack** (and **ministack** — the ministack.org image — as an interchangeable AWS-emulation engine, `provides: aws`), **NATS** (JetStream), **Kafka** (Redpanda default; `image:` escape hatch to Apache Kafka), **RabbitMQ**. Each is a standard `provides:`/`exports:`/`defaultPort:` engine template with a typed healthcheck, health-gated by the up saga and reaped by `shared gc`. Native NATS/Redpanda are the default messaging stack; LocalStack is opt-in for AWS-API fidelity. *Pairs with #19.* + +**19. Imperative resource commands — db / s3 / queue / stream / aws · ~5w (db+s3 ~2w now).** ([spec 29](specs/29-resource-commands.md)) +Per-domain verbs creating tenant-scoped resources via the same Provisioner the declarative block runs at `up`: `db` (create/user/grant/list/drop/gc), `s3` (mb/rb/ls/lifecycle/versioning/policy/cors), `queue`/`topic`/`stream`, and a thin `aws --` passthrough (host aws-cli with `--endpoint-url` + dev creds prefilled). Each verb mirrors the provision-phase `lock → 127.0.0.1 overlay → provisioner → ledger → event` flow. db+s3 ship now on the existing PG/MinIO substrate; queue/topic/stream are gated on #18. *The imperative escape hatch beside the declarative default.* + +**20. Data-plane resource layer (`resources:` block + Provisioner family) · ~4.5w — the substrate.** ([spec 27](specs/27-resource-layer.md)) +Generalizes provision-on-demand into a `Resource` model + a per-engine `Provisioner` family (`internal/resource`), plus a **declarative `resources:` block in `devstack.yaml`** (the **recommended** path — committed, reproducible, provisioned by a new up-saga phase that subsumes today's Postgres provision phase). Reuses the free-text `kind` ledger (zero migrations), the flock, and the host-port overlay; per-resource predictable|generated credential policy via the #16 secrets Pusher (+ a net-new crypto/rand keygen). Never auto-drops on config deletion (drift reported); single-owner per resource (no cross-project grants yet). *The load-bearing generalization #18/#19 sit on.* + +**21. CLI completeness & README reconciliation · 2w — ships FIRST (v0.3.0).** ([spec 26](specs/26-cli-completeness.md)) +A truth-pass over already-shipped code: fix the stale README (up/down/secrets/trust/dns/tunnel are **shipped**, not 🚧; `logs` honestly marked v2/spec 16), land the real `shell` (a new interactive docker seam — `os.Stdin` + a real `-it` TTY, verbatim child exit code, *not* the stderr-capturing `ExecRunner`), `up --rebuild/--skip-clone` + the existing `--health-timeout`, `self update --force`, standalone `tunnel up/down` (honoring spec 05's `secret://` refusal), a machine-wide `workspace` registry + `workspace list`, and the reserved post-1.0 stubs. *The credibility gate; zero dependency on the cloud work.* + --- ## At a glance @@ -100,3 +116,7 @@ Conventional commits on `main` → `svu next --v0` → tag + goreleaser **in one | 15 | Template & Dockerfile authoring (TUI) | 2.5w | v0.2 beta DX (M8) · [spec 23](specs/23-template-authoring.md) | | 16 | `.env` ingestion → secrets/vars | 2.5w | v0.2 beta DX (M8) · [spec 24](specs/24-env-ingestion.md) | | 17 | Release automation + 0.x versioning | 0.75w+0.75w | v0.2 gate (M8) · [spec 25](specs/25-release-automation.md) | +| 18 | Local-cloud engine templates (LocalStack/ministack/NATS/Kafka/RabbitMQ) | 2.5w | v0.x (M9) · [spec 28](specs/28-cloud-engine-templates.md) | +| 19 | Imperative resource commands (db/s3/queue/stream/aws) | ~5w | v0.x — db/s3 now (M9) · [spec 29](specs/29-resource-commands.md) | +| 20 | Data-plane resource layer (`resources:` + Provisioners) | ~4.5w | v0.x (M9) · [spec 27](specs/27-resource-layer.md) | +| 21 | CLI completeness & README reconciliation | 2w | v0.3.0 — ships first (M9) · [spec 26](specs/26-cli-completeness.md) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c31859e..b2fb7ec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,6 +92,27 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf **Sequencing within M8:** M8.0 → M8.1 (lands `internal/prompt` + the shared emitter) → M8.2 (reuses both) → M8.3 (reuses prompt, adds the heaviest net-new backend). After each charm-dep add: re-run `make vuln` + the `CGO_ENABLED=0` static cross-build; no build tags may creep in. +### M9 — Local-cloud platform lane (post-M8, v0.x beta) · **~14w** +> Generalizes provision-on-demand (Postgres-only today) into a full data-plane resource layer and adds cloud-emulation engines, turning the shared-infra tool into a local cloud. Strictly additive; stays 0.x. Specs: [26](specs/26-cli-completeness.md) (CLI/README reconcile) · [27](specs/27-resource-layer.md) (resource model + Provisioner family) · [28](specs/28-cloud-engine-templates.md) (cloud engines) · [29](specs/29-resource-commands.md) (imperative verbs). Every mutation goes through `internal/lock`; engine tools (mc/aws/nats/rpk) shell out behind `internal/` interfaces (CGO-free rule); only generated artifacts are determinism-gated, ledger/runtime ops are not. + +**M9.0 — CLI completeness & README reconciliation (the credibility gate) · 2w.** ([spec 26](specs/26-cli-completeness.md)) +- Reconcile README Commands/Status to the shipped surface (up/down/secrets/trust/dns/tunnel are shipped; mark `logs` honestly v2/spec 16) — a truth-pass over merged code, lowest risk, ships first (cut v0.3.0). +- Real interactive `shell` (new docker seam: `os.Stdin` + real `-it` TTY, no stderr capture, verbatim child exit code); `up --rebuild/--skip-clone` + exposed `--health-timeout`; `self update --force`; standalone `tunnel up/down` (spec 05 secret-guard); machine-wide `workspace` registry (thin pointer keyed by Docker context, written on `up` under the flock, `--prune`-only removal) + `workspace list`; reserved post-1.0 stubs. + +**M9.1 — Data-plane resource substrate · ~4.5w.** ([spec 27](specs/27-resource-layer.md)) +- `internal/resource`: Resource model + `Provisioner` interface; refactor Postgres provisioning behind it (behavior-preserving) FIRST. New `crypto/rand` keygen in `internal/secrets`; predictable|generated credential policy via the #16 Pusher. +- Declarative `resources:` block (additive, validated by the cross-ref resolver) + a new up-saga `resources` phase subsuming the Postgres provision phase (drift reported, never auto-dropped). `resource list|show|create|rm|gc`; new `RemoveProvisioned(project,kind,name)` ledger op (free-text kind, no migration); `workspace destroy --purge-data` (opt-in, confirm-gated). + +**M9.2 — Cloud-emulation engine templates · 2.5w.** ([spec 28](specs/28-cloud-engine-templates.md)) +- Net-new localstack/ministack(AWS-emulation, `provides: aws`)/nats/kafka(Redpanda default)/rabbitmq engine templates on `devstack_shared`; `template new --kind engine` scaffold; `shared status`/`gc` coverage; info-level doctor `bin.*` probes. Confirm ministack.org image/port/SERVICES/health before authoring. +- Resolve the secondary-port export-attr→port map (or drop monitor/admin attrs). Native NATS/Redpanda default; LocalStack opt-in. + +**M9.3 — Imperative resource verbs · ~5w (db+s3 ~2w, messaging ~3w gated on M9.2).** ([spec 29](specs/29-resource-commands.md)) +- `db` + `s3` on the PG/MinIO substrate — ship in v0.x now (own `FreeHostPort` purpose/base per engine; avoid the 45432 pg-provision base). Thin `aws --` shim. +- `queue`/`topic`/`stream` (nats/redpanda native default, sqs/sns via LocalStack opt-in) — gated on M9.2 engines. Cross-kind `db gc` waits on spec 13's reaper. + +**Sequencing within M9:** M9.0 (now) → M9.1 (the substrate everything needs) → M9.2 (engines) ‖ M9.3-db/s3 (parallel, no new engine) → M9.3-messaging (after engines). Re-run `make vuln` + the CGO=0 cross-build after each engine-tool/dep add. + --- ## Totals @@ -105,6 +126,7 @@ Effort is **person-weeks at production OSS quality** (tests + docs + cross-platf | Hardening/GA (M7) | 6 | +~2 months | | **Full v1 (all pillars)** | **54** | **~13–15 months** | | Beta DX lane (M8, 0.x — post-GA) | ~8 | +~2.5 months | +| Local-cloud platform lane (M9, 0.x) | ~14 | +~4 months | Calendar applies a 0.6–0.75 throughput factor (context-switching, Docker/WSL2/macOS debugging, dependency churn, docs, CI). Treat as planning ranges, not commitments. diff --git a/docs/specs/26-cli-completeness.md b/docs/specs/26-cli-completeness.md new file mode 100644 index 0000000..bcf6cad --- /dev/null +++ b/docs/specs/26-cli-completeness.md @@ -0,0 +1,157 @@ +# Spec 26 — CLI completeness & README reconciliation + +**Module:** `internal/cli` (+ `internal/docker`, `internal/state`, `README.md`) · **Milestone:** M6 (GA-blocking polish) · **Effort:** ~1–1.5w + +## Purpose +Finish what the surface already promises, and stop the docs from lying. The command tree in `internal/cli/root.go:79-101` is far ahead of `README.md`: `up`/`down`/`status`/`secrets`/`trust`/`dns`/`tunnel`/`init`/`import`/`uninstall`/`template new`/`secrets ingest`/`shared`/`ws`/`store`/`alias`/`self` are all wired and tested, yet the README Commands table still marks half of them "🚧 planned". The only real stubs are `shell` and `logs` (`internal/cli/stubs.go`, currently tagged `M2`). + +This spec (a) **reconciles the README to reality**; (b) lands the **one** genuinely-new command this spec owns — `shell` — plus the small promised-but-missing flags that thread into already-built modules (`up --rebuild/--skip-clone`, exposing the **already-present** `UpDeps.HealthTimeout`, `self update --force`); (c) adds the **one** net-new state concept (a thin machine-wide `workspace` registry behind `workspace list`); (d) closes the `tunnel up/down` surface gap **honoring spec 05's design** (not re-deriving it); and (e) reserves the post-1.0 verbs in the tree as milestone-tagged stubs per [spec 07](07-cli-and-aliasing.md) lines 43-48. + +**This is the completeness spec, not a redesign of other specs.** Two commands that the surface lists are already **owned, in full, by their feature specs** — this spec **cross-references them and does NOT re-specify them**: +- **`logs`** — owned by [spec 16](16-logs-and-dashboard.md) (read-only SDK multiplex + demux + ring-buffer + re-attach; v2). spec 07 line 27 tags `logs # spec 16`. This spec only **re-tags the `logs` stub** to `v2 (spec 16)` and reconciles the README row — it does **not** ship a contradictory shell-out design. +- **`workspace destroy --purge-data`** — owned by [spec 13](13-doctor-diagnostics-and-teardown.md) (the teardown spec, line 79 + acceptance line 127). This spec cross-references it; the destructive tenant/volume teardown is spec 13's to design. + +Nothing here introduces a new engine, provisioner, or generation path; determinism of generated artifacts is unaffected (none of these verbs write generated files). + +## Decisions +- **README is wrong, not the code — fix the docs.** Rewrite the Commands table to ✅ everything wired in `root.go`, document the undocumented-but-shipped verbs (`init`, `import`, `status`, `uninstall`, `template new`, `secrets ingest`, `shared gc/doctor`), mark `shell` as the one closing gap, and mark `logs` as **v2 (spec 16)** — not "shipped". +- **`shell` is the only genuinely-new command this spec owns.** spec 07 lists `shell ` (line 26) with `ValidArgsFunction` completion (line 13) but no dedicated behavior spec — so its design lands here. It is host-side, over the compose project, reusing `internal/docker` — no new module. +- **`logs` is deferred to spec 16, not re-designed.** spec 16 already specifies the full read-only-SDK pipeline and JSON contract. Re-implementing it here as a `docker compose logs` shell-out would contradict spec 16's read-only/no-flock/demux/re-attach design and its `ts/service/project/stream/container/line` JSON shape. This spec re-tags the stub and reconciles the README; if a thin non-TUI cut is pulled forward, it lands **under spec 16** with spec 16's approach. +- **No new lifecycle phases.** `tunnel up/down` are standalone cobra commands managing the cloudflared container directly (mirroring the standalone-resource-command pattern, **not** a saga phase), and they obey [spec 05](05-networking.md)'s `[]Route`-derived ingress + the `secret://` refusal guard. `up --rebuild/--skip-clone/--health-timeout` are flags threaded into the existing `orchestrate.UpDeps` (semantics owned by [spec 09](09-orchestration-and-onboarding.md) / [spec 10](10-health-readiness-and-ordering.md)). +- **`workspace list` needs a registry; nothing records workspace roots today.** The ledger is keyed by Docker context and rows are *project*-scoped (`service_ref.project`), with no path back to a workspace root. Add one **thin pointer table** (`workspace(ctx, name, root, …)`) written at `up`; derive everything else by re-reading each `workspace.yaml` at list time. Migration discipline is owned by [spec 08](08-state-locking-and-lifecycle.md) (additive, forward-only). +- **Reserve, don't implement, the post-1.0 verbs.** `db` (children), `dashboard`, `ide`, `template push`, `telemetry` register as milestone-tagged `stub()` placeholders (existing `internal/cli/stubs.go` helper) exactly as [spec 07](07-cli-and-aliasing.md) lines 43-48 intend. `shell` graduates *out* of `stubs.go`; `logs` stays a stub, re-tagged to `v2 (spec 16)`. +- **`--json`/`--quiet` contract is non-negotiable for the new headline verbs** (`workspace list --json`, `tunnel up --json`) — same posture as `doctor --json`/`status --json` (ARCHITECTURE §7.9). + +## README reconciliation (the corrected Commands table) +Replace the stale `README.md` Commands table with the true status: + +| Command | New status | Note | +|---|---|---| +| `up` / `down` | ✅ | saga lands `up`/`down` (M2/M6); add `--rebuild/--skip-clone` + expose `--health-timeout` (this spec; semantics spec 09/10). | +| `status` | ✅ | multi-repo git + service + ref-graph table; was undocumented. | +| `init` | ✅ | workspace/project authoring entry point; was undocumented. | +| `import` | ✅ | devdock → two-file migrator ([spec 14](14-self-update-and-migration.md)); was undocumented. | +| `secrets login/keygen/ingest/status/logout` | ✅ | M4 landed; **`ingest`** (`.env` import) was undocumented. | +| `trust install/uninstall/status` | ✅ | M5 landed; README said planned. | +| `dns setup/status/remove` | ✅ | M5 landed; README said planned. | +| `tunnel login/create/route` | ✅ | landed; **add `up`/`down`** (this spec; design [spec 05](05-networking.md)). | +| `template list/lint/test/init/new` | ✅ | **`new`** (scaffold) was undocumented. | +| `shared status/gc/doctor` | ✅ | `gc`/`doctor` were undocumented. | +| `ws clone/sync/status/git` | ✅ | documented. | +| `workspace destroy` | ✅ | landed (data-preserving); **`--purge-data` is [spec 13](13-doctor-diagnostics-and-teardown.md)**. **`workspace list`** added (this spec). | +| `uninstall` | ✅ | machine-global teardown ([spec 13](13-doctor-diagnostics-and-teardown.md)); was undocumented. | +| `self check/update` | ✅ | add `--force` (this spec; design [spec 14](14-self-update-and-migration.md)). | +| `store` / `alias` | ✅ | documented. | +| `shell` | ✅ (was 🚧) | the one stub closed by this spec. | +| `logs` | 🔭 v2 (spec 16) | full read-only-SDK design owned by [spec 16](16-logs-and-dashboard.md). | +| `db` / `dashboard` / `ide` / `template push` / `telemetry` | 🔭 v2 | reserved-in-tree stubs (this spec, per [spec 07](07-cli-and-aliasing.md)). | + +Also update the README "Status" block and the quickstart "working today" list to reflect that `up`/`down`/`shell`/secrets/networking are present, and that `logs`/dashboard are the v2 observation layer. + +## The one new command, designed + +### `shell [--project P] [-- cmd...]` +Interactive exec into a running service container of the current (or `--project`) project stack. +``` +devstack shell api # bash→sh in project "api"'s primary service +devstack shell api -- psql # run a one-off command instead of a login shell +``` +- Resolve the compose project (`devstack-`) and the **service name**. With one service, default to it; with many, require an explicit `` (completion via `ValidArgsFunction`, [spec 07](07-cli-and-aliasing.md) line 13). `compose exec ` lets compose resolve the container itself — no manual SDK container enumeration is needed here. +- Shell out to `docker compose -p devstack- -f exec ` with **all three std streams inherited** (interactive TTY). This is a **new** path on the `Runner` seam: `ExecRunner.Run` (`internal/docker/compose.go:28`) wires `Stdout=os.Stdout` (line 32) and `Stderr=io.MultiWriter(os.Stderr, &buf)` (line 34, **captures** stderr) but **never sets `Stdin`** — reusing it would hang the shell with no input echo. Add `Compose.Exec` + an `InteractiveRunner` (or an `interactive bool` on the runner) that sets `cmd.Stdin = os.Stdin`, requests a real TTY (`-it`), does **no** stderr capture (the child owns the terminal), and returns the child's exit code verbatim. +- Default command when no `-- cmd`: probe `bash`, fall back to `sh` (Q-SHELL-DEFAULT-CMD). Propagate the container exit code as the process exit code. + +## The promised-but-missing flags & verbs + +### `up` flags (thread into `orchestrate.UpDeps`) +Semantics are owned by [spec 09](09-orchestration-and-onboarding.md) (saga flags) and [spec 10](10-health-readiness-and-ordering.md) (`--health-timeout`); this spec only wires the CLI flags. +- `--rebuild` — force `compose build --no-cache` for the project's images before `up` (distinct from the existing `--build`, which honors the generate-ledger's selective-rebuild hash). Adds a **new** `UpDeps.Rebuild` field; `composeUpPhase` (`internal/orchestrate/up.go:461`) currently calls `c.Build(ctx, false, …)` (noCache=false), so `--rebuild` must pass `noCache=true`. +- `--skip-clone` — skip the clone/sync phase for a workspace whose repos are already on disk. Adds a **new** `UpDeps.SkipClone` field so `BuildUp` omits the clone phase. +- `--health-timeout D` — **the field already exists** (`UpDeps.HealthTimeout`, `internal/orchestrate/up.go:67`, already consumed in `gateShared`, up.go:414-416); this spec only exposes the CLI flag and assigns it. `0`/unset → spec-10 default. + +### `self update --force` +Design owned by [spec 14](14-self-update-and-migration.md). `selfupdate.Options` (`internal/selfupdate/update.go:21`) currently has only `Version` — add `Force bool`. `--force` re-installs the resolved release even when `res.UpToDate` (repair a corrupted/partial binary) but **still refuses package-managed installs** — the `CanSelfReplace()` refusal fires in **both** `update.go:44` and the CLI re-check in `newSelfUpdateCmd`; `--force` overrides neither. Threads as `selfupdate.Options{Force: true}`. + +### `tunnel up` / `tunnel down` +Design owned by [spec 05](05-networking.md) (lines 13/32); today `internal/cli/tunnel.go` wires only `login/create/route`. This spec closes the standalone-command surface gap **without re-deriving the design**: +- `tunnel up [--detach]` — bring the managed `cloudflared` container up against the shared stack, ingress rendered from the proxy `[]Route` ([spec 05](05-networking.md)). **Default-DOWN stays the default.** Honor spec 05's guard: **refuse to tunnel a service whose env carries a non-local `secret://` value without an explicit override** (spec 05 acceptance line 32), printing the override hint. Mirrors the standalone resource-command shape, not a saga phase. +- `tunnel down` — stop the managed tunnel container; leave credentials/routes intact (reversible). `--json` reports `{tunnel, state}`. + +### `workspace destroy --purge-data` — DEFERRED to [spec 13](13-doctor-diagnostics-and-teardown.md) +Not designed here. Today `destroy` is **data-preserving by design** (`internal/cli/destroy.go:117` `destroyWorkspace` never passes `-v`; the `g.JSON && !yes` guard is at destroy.go:54; the `Type 'yes'` prompt at destroy.go:65). The destructive `--purge-data` opt-in — drop this workspace's provisioned roles/dbs/buckets and remove its named volumes, under a stronger confirm — is **owned by [spec 13](13-doctor-diagnostics-and-teardown.md)** (line 79, acceptance line 127), which also owns the per-engine drop seam (internal/provision today is Postgres-only `EnsureProject`; MinIO bucket lifecycle is unbuilt) and the host-reachability concern (host-side pgx tenant drops need the 127.0.0.1 up-time provision overlay/port live). This spec only documents in the README that `--purge-data` is spec-13 territory. + +## `workspace list` + the registry (the one new state concept) +Nothing records where a workspace root lives; the ledger only knows projects. Add a **thin pointer table** (additive, forward-only migration — append `schemaV3` to the `migrations` slice at `internal/state/migrations.go:14`; never edit `schemaV1`/`schemaV2`; discipline owned by [spec 08](08-state-locking-and-lifecycle.md)): + +```sql +CREATE TABLE IF NOT EXISTS workspace ( + ctx TEXT NOT NULL, -- Docker context (same keying as the rest of the ledger) + name TEXT NOT NULL, -- workspace.yaml `name` + root TEXT NOT NULL, -- absolute path to the workspace root (where workspace.yaml lives) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_up_at TEXT, -- refreshed on each successful `up` + PRIMARY KEY (ctx, root), + FOREIGN KEY (ctx) REFERENCES docker_context(name) ON DELETE CASCADE +); +``` +- **Written on `up`** (inside the flock, alongside the other ledger mutations): upsert `(ctx, name, root)` and set `last_up_at`. Read-only commands do **not** write it (reads stay lock-free, ARCHITECTURE §"reads are lock-free snapshots"). New CRUD on `*state.DB`: `RegisterWorkspace(name, root)`, `ListWorkspaces()`, `PruneWorkspace(root)`. +- **`workspace list [--json] [--prune]`** reads the table lock-free; for each row: if `root` no longer exists on disk, mark it `stale` (and `--prune` drops it); otherwise re-read `/workspace.yaml` to get the live project set and join the ledger (`DB.ProjectsUsing`, `internal/state/ledger.go:151`; `DB.AllRefs`, `internal/state/ledger.go:170`) to show each workspace's shared-service refs. The registry stays a **pointer**, never a denormalized cache — the committed `workspace.yaml` remains the single source of truth (Q-WS-REGISTRY). A workspace.yaml that fails to parse degrades to a flagged row, not a failed command. +- Plain output: a table of `NAME · ROOT · PROJECTS · SHARED REFS · LAST UP`. `--json`: `[{name, root, projects:[...], shared:[{service, refs}], last_up_at, stale}]`. + +``` +$ devstack workspace list +NAME ROOT PROJECTS SHARED LAST UP +acme ~/src/acme api, web postgres-16 (2), redis (1) 2026-06-30 09:41 +demo ~/play/demo [stale: gone] — — 2026-05-02 14:10 +``` + +## Reserved post-1.0 verbs (tree-only stubs) +Register via the existing `stub()` helper (`internal/cli/stubs.go`) so help/completions stay consistent, matching [spec 07](07-cli-and-aliasing.md) lines 43-48 exactly; **remove** `shell` from `addStubCommands` (it graduates), and **re-tag** the `logs` stub from `M2` to `v2 (spec 16)`: +```go +root.AddCommand( + stub("dashboard", "Live TUI cockpit", "v2"), // spec 16 + stub("ide", "Generate devcontainer/.code-workspace/launch configs", "v2"), // spec 17 + stub("telemetry", "Opt-in usage telemetry (default OFF)", "later"),// spec 20 + // db: parent hosting `gc` (v1, spec 13) + snapshot|restore|reset|list|pull as v2 stubs (spec 15) + // template push|add|update|diff|ls|verify hang off the existing `template` cmd as v2 stubs (spec 19) +) +``` +`db gc` is v1-scoped (its provisioned-ledger substrate exists — `DB.OrphanedProvisioned`, `internal/state/ledger.go:313`) and is **owned by [spec 13](13-doctor-diagnostics-and-teardown.md)** (line 63); it may land as a real subcommand under the otherwise-stubbed `db` parent when spec 13 ships it. The `db snapshot/restore/reset/list/pull` children stay stubs ([spec 15](15-db-snapshot-restore.md), v2). + +## Behavior +1. **README rewrite** — replace the stale Commands table, Status block, and quickstart list; `logs` → v2 (spec 16); `workspace destroy --purge-data` → spec 13. No code dependency, ships independently first. +2. **`shell`** — resolve project+service → build `Compose{Project, File}` → `Exec(service, cmd, interactive=true)` shelling `docker compose … exec` with all three std streams inherited → propagate the child exit code. +3. **`up` flags** — `--rebuild` (new `UpDeps.Rebuild`, forces `noCache=true`), `--skip-clone` (new `UpDeps.SkipClone`, omits the clone phase), `--health-timeout` (expose flag for the **existing** `UpDeps.HealthTimeout`). +4. **`self update --force`** — add `selfupdate.Options.Force`; re-installs over an up-to-date binary; the package-manager refusal in both `update.go` and the CLI is unchanged. +5. **`tunnel up/down`** — standalone commands bringing the managed cloudflared container up/down against the shared stack; default-DOWN; spec 05 `secret://` refusal guard on `up`; `--json` headline. +6. **`workspace` registry + `list`** — apply the additive `schemaV3` migration; `up` upserts the row under the flock; `workspace list` reads lock-free, re-reads each root's `workspace.yaml`, joins refs, flags stale roots, supports `--prune`/`--json`. +7. **Reserved stubs** — register `dashboard`/`ide`/`telemetry` (+ `db`/`template` v2 children) as `stub()`s per spec 07; promote `shell` out of `stubs.go`; re-tag `logs` to `v2 (spec 16)`. + +## Verified constraints / gotchas +- **Most of the "stubs" the README lists aren't stubs.** Only `shell`/`logs` are in `internal/cli/stubs.go`; `up`/`down`/`secrets`/`trust`/`dns`/`tunnel`/`status`/`init`/`import`/`uninstall`/`self` are fully wired in `root.go:79-101`. Reconcile docs *first*; don't re-implement working verbs. +- **`logs` is spec 16's, not this spec's.** spec 16 (`16-logs-and-dashboard.md`) owns the full read-only-SDK design (`ContainerLogs(Follow,…)` + `stdcopy` demux + ring-buffer + die/start re-attach, **lock-free, never shells `docker compose`**, JSON `ts/service/project/stream/container/line`). Do **not** ship a contradictory `docker compose logs -f` shell-out here. The current `ContainerLogs` wrapper (`internal/docker/inspect.go:136`) returns a finished string and cannot follow — extending it for follow is spec 16's job. +- **`shell` needs a TTY/stdin path that `ExecRunner` doesn't provide.** `ExecRunner.Run` (`compose.go:28`) wires `Stdout`/`Stderr` and *captures* stderr into a buffer, never sets `Stdin`. An interactive `compose exec` requires `cmd.Stdin = os.Stdin`, a real TTY (`-it`), and **no** stderr capture (the child owns the terminal). Add a distinct interactive runner/`Exec` method; don't reuse the capturing path. +- **`tunnel up` must keep spec 05's secret guard.** Default state is DOWN; `up` refuses (with override hint) any service whose env carries a non-local `secret://` value (spec 05 acceptance line 32). Ingress is rendered from the same `[]Route` as local routing — no re-derivation. +- **`workspace destroy --purge-data` is spec 13's.** internal/provision is Postgres-only `EnsureProject` (no drop), MinIO `mc rb` is unbuilt, and host-side tenant drops need the 127.0.0.1 up-time provision overlay live — all design concerns spec 13 owns. Don't invent the drop seam here. +- **`workspace list` must be keyed by Docker context like everything else.** The same workspace root under WSL2's Desktop daemon vs in-distro `dockerd` has different refs; the `workspace` table PK includes `ctx` ([spec 03](03-workspaces-and-shared-services.md)). Listing reads only the current context's rows. +- **Don't denormalize the workspace registry.** Store only the root pointer + timestamps; re-derive projects/refs from the committed `workspace.yaml` at list time. Roots that vanished are flagged `stale`, never silently dropped — a moved checkout shouldn't erase history without `--prune`. +- **`self update --force` does not override package management.** Force means "re-install even if up-to-date"; the `CanSelfReplace()` refusal (both `update.go:44` and the CLI) holds for Homebrew/dpkg/rpm-managed binaries. +- **The migration is forward-only and additive.** Append `schemaV3` to the `migrations` slice (`migrations.go:14`); never edit `schemaV1`/`schemaV2`. The `workspace` table CASCADEs on `docker_context` deletion like every other ledger table. Migration discipline is owned by [spec 08](08-state-locking-and-lifecycle.md). +- **Reserved stubs must exit 0 with a clear notice** (the existing `stub()` RunE) so `completion`/scripts that probe `--help` don't see spurious failures. +- **Single-static-binary rule holds:** `shell`/`tunnel up` shell out to `docker`/`cloudflared` (external binaries already required, DECISIONS D5/D12); no new pure-Go-vs-CGO dependency. Generated-artifact determinism is unaffected — none of these verbs write generated files (ledger/runtime ops are not determinism-gated). + +## Acceptance criteria +- [ ] `README.md` Commands table marks `up`/`down`/`status`/`secrets`/`trust`/`dns`/`tunnel`/`init`/`import`/`uninstall`/`template new`/`secrets ingest`/`shared gc`/`shared doctor`/`self` as implemented; `shell` is documented as shipped; `logs` is marked **v2 (spec 16)**; `workspace destroy --purge-data` is marked **spec 13**; only post-1.0 verbs are flagged future. +- [ ] `devstack shell api` opens an interactive shell (bash→sh) in project `api`'s service, forwards stdin/TTY, and propagates the container's exit code; `shell api -- psql` runs the one-off command. +- [ ] `up --skip-clone` runs no clone phase; `up --rebuild` forces a `--no-cache` build; `up --health-timeout 5s` overrides the spec-10 readiness deadline via the existing `UpDeps.HealthTimeout`. +- [ ] `self update --force` re-installs over an up-to-date binary but still refuses (with the upgrade hint) on a Homebrew/dpkg/rpm-managed install. +- [ ] `tunnel up` starts the managed cloudflared container with default-DOWN as the default and **refuses (with override hint) any service carrying a non-local `secret://`**; `tunnel down` stops it, leaving credentials/routes intact; both honor `--json`. +- [ ] `up` records the workspace in the new `workspace` table under the flock; `workspace list` enumerates every workspace for the current Docker context with projects + shared-service refs; a removed root shows `stale` and `--prune` drops it; an unparseable `workspace.yaml` degrades to a flagged row, not a failed command. +- [ ] The `workspace` table lands as an additive `schemaV3` migration that re-applies idempotently and CASCADE-deletes with its `docker_context`. +- [ ] `dashboard`/`ide`/`telemetry` (and the `db`/`template` v2 children) appear in `--help`/completions as milestone-tagged stubs (exit 0, clear notice); `shell` no longer appears in `stubs.go`; `logs` remains a stub re-tagged `v2 (spec 16)`. +- [ ] `make ci` + `make determinism` stay green (no generated-artifact change). + +## Dependencies / consumers +Consumes `internal/docker` (the `Compose` driver + a new interactive `Runner`/`Exec` seam for `shell` and `tunnel up`; `compose.go`), `internal/state` + `internal/lock` (the new `workspace` table + CRUD, written under the flock; migration discipline per [spec 08](08-state-locking-and-lifecycle.md)), `internal/orchestrate` (the `UpDeps` flags), `internal/selfupdate` (`Options.Force`), `internal/tunnel` (cloudflared lifecycle, design [spec 05](05-networking.md)), `internal/config` (re-reading each `workspace.yaml` at list time). **Cross-references (does not re-specify):** [spec 16](16-logs-and-dashboard.md) (`logs`), [spec 13](13-doctor-diagnostics-and-teardown.md) (`workspace destroy --purge-data`, `db gc`, `uninstall`), [spec 05](05-networking.md) (`tunnel up/down`), [spec 09](09-orchestration-and-onboarding.md)/[spec 10](10-health-readiness-and-ordering.md) (`up` flag semantics), [spec 14](14-self-update-and-migration.md) (`self update`), [spec 07](07-cli-and-aliasing.md) (reserved verbs). Consumed by end users, by [spec 16](16-logs-and-dashboard.md) (the dashboard reuses the `workspace list` projection), and by CI scripts (the `--json` contracts). No new external dependency; no generation-path change. + +## Open questions +**Q-WS-REGISTRY** (new) — thin pointer table vs richer denormalized cache. **Decision:** thin pointer (name+root+ctx+timestamps), re-derive projects/refs at list time; never duplicate the committed `workspace.yaml`. **Q-WS-REGISTER-WHEN** (new) — which commands write the registry row. **Decision:** write on `up` under the flock; read-only commands never write (reads stay lock-free). **Q-SHELL-DEFAULT-CMD** (new) — default shell when `shell ` gets no `-- cmd`. **Decision:** probe bash→sh. **Q-LOGS-OWNERSHIP** (new) — keep `logs` fully owned by spec 16 (read-only-SDK design) rather than re-spec it here. **Decision:** spec 26 re-tags the stub + reconciles the README only. Inherits [Q-PLATFORM](../OPEN-QUESTIONS.md) (native-Windows `shell`/TTY is best-effort; WSL2 is the supported path) and [Q-DAEMON](../OPEN-QUESTIONS.md) (no daemon → registry refreshed lazily on `up`). \ No newline at end of file diff --git a/docs/specs/27-resource-layer.md b/docs/specs/27-resource-layer.md new file mode 100644 index 0000000..03c8688 --- /dev/null +++ b/docs/specs/27-resource-layer.md @@ -0,0 +1,200 @@ +# Spec 27 — The data-plane resource layer (resources inside shared services) + +**Module:** `internal/resource` (+ `internal/provision`, `internal/state`, `internal/lock`, `internal/generate`) · **Milestone:** v2 · **Effort:** ~6w (feature #27) + +## Purpose +The shared stack already hosts more than *containers* — it hosts **per-project resources that live inside those containers**: databases, roles/users, S3 buckets and object-lifecycle policies, message queues, streams, topics. Today exactly one such resource is modelled: the per-project Postgres role+database that `provisionPhase` ([`internal/orchestrate/provision.go`](../../internal/orchestrate/provision.go) lines 132–207) creates on the warm shared engine. This spec **generalizes provision-on-demand into a first-class resource layer** — one `Resource` model, one `Provisioner` interface family, one mutation pattern — so every engine's in-container resources are provisioned, tracked, surfaced, and reclaimed the same tenant-safe way. It is the architecture other data-plane specs build on: spec 28 defines the resource **templates**, spec 29 the ergonomic **per-domain verbs** (`devstack db …`, `devstack s3 …`, `devstack queue …`). It is **post-GA (v2)** — it sits on top of the M2 substrate (the `provisioned` ownership ledger, the flock spine, the up-time port overlay) and the just-landed secrets `Pusher` (feature #16), none of which existed before M2/M4. + +> **What this is not.** This is not a new *engine* (that is shared-service lifecycle, [spec 03](03-workspaces-and-shared-services.md)) and not a data *workflow* (snapshot/restore, [spec 15](15-db-snapshot-restore.md)). It is the layer **between** them: the resources a project owns *within* an already-running shared engine. Spec 03 ref-counts the Postgres container; spec 27 owns the `_db` inside it; spec 15 dumps and restores that db's bytes. + +## The resource model +A **Resource** is the tuple `(engine, kind, name, owner-project, attributes)`: + +- **engine** — the shared template's `provides:` capability (`postgres`/`redis`/`minio`/…), the same value the ledger already stores in the `engine` column and that `internal/scaffold/build.go` (line ~53) requires a shared template to declare (it rejects any service whose template has an empty `provides:`). A resource always belongs to exactly one shared *instance* keyed `(engine, major)`. +- **kind** — `database | role | user | bucket | lifecycle | queue | stream | topic | redis_index | …`. This is the `provisioned.kind` column, which is **already free-text** (currently `database|role|bucket|redis_index`, the kinds `provisionPhase` and `AllocateRedisIndex` record today), so new kinds need **no migration** ([spec 08](08-state-locking-and-lifecycle.md), migrations are append-only/forward-only). +- **name** — the engine-level identifier (`acme_db`, `acme` role, `acme-uploads` bucket, `acme.events` stream). Derived from the owner project by default (tenant namespacing, below); explicit names are allowed but still recorded against one owner. +- **owner-project** — the `provisioned.project` column. **Tenant-scoping is the load-bearing invariant**: every provisioner call is parameterized by owner, and a project's resources are namespaced by project so one project's commands can **never** read or mutate another's — the same isolation that makes the shared stack the differentiator (`provisionPhase` already creates `_db OWNER ` with `REVOKE … FROM PUBLIC`). +- **attributes** — the connection-surfacing facts a consumer needs (`host`, `port`, `user`, `database`, `bucket`, `subject`, …) plus a credential reference. Non-secret attrs resolve through the `${ref}` graph resolver; **secret** attrs (`password`/`secretkey`/`secret`/`token`) are *rejected* as inline refs and flow only via `env.import` (the spec-04 coupling — see `secretAttrs` in [`internal/generate/resolver.go`](../../internal/generate/resolver.go)). + +The ledger row is unchanged: `provisioned(ctx, project, kind, name, created_at)`, keyed by Docker context like the rest of the ledger. Attributes are **derived, not stored** (re-resolved from the template `exports:` + the owner project on read), so there is exactly one source of truth and nothing to drift. + +## Decisions +- **One ledger, one ownership table for everything.** Reuse `provisioned(ctx, project, kind, name, created_at)` verbatim. `kind` is free-text (today `database|role|bucket|redis_index`) → `user|bucket|lifecycle|queue|stream|topic` add zero migrations. CRUD stays `RecordProvisioned` / `ProvisionedFor` / `AllProvisioned` / `OrphanedProvisioned` / `RemoveProvisionedForProject` ([`internal/state/ledger.go`](../../internal/state/ledger.go) lines 290–341). +- **A `Provisioner` per engine, behind a small mockable seam — the `provision.Conn` pattern, repeated.** `provision.Postgres{}.EnsureProject` already proves the shape (existence-guarded, idempotent, interface-injected for tests). New engines get their own minimal seam; the orchestrator only ever sees the `Provisioner` interface. +- **External engine tools are shelled out behind `internal/` interfaces; pure-Go SDKs are preferred where they keep the single static binary.** `pgx/v5` is the only live runtime SDK and stays in-process. S3/MinIO use the pure-Go `aws-sdk-go-v2` S3 client where possible, `mc`/`madmin` only for admin IAM; NATS/Kafka prefer pure-Go clients (`nats.go`, `franz-go`), shelling `nats`/`rpk` only when the SDK can't express the op. Every external binary is an `info`-level `doctor` probe (absence degrades that engine's resource verbs, never blocks `up`) — same posture as `mkcert`/`cloudflared`/`pg_dump` ([DECISIONS D11/D12](../DECISIONS.md), [spec 15](15-db-snapshot-restore.md)). +- **Two consumption paths, one substrate.** Imperative `resource …` commands are **standalone cobra commands**, not saga phases; the declarative `resources:` block is provisioned by **one** new `up` saga phase. Both call the *same* provisioners against the *same* ledger under the *same* flock. There is no second code path. +- **Imperative commands mirror `provisionPhase` exactly.** `lock.WithLock(ctx, LockPath, …)` → resolve the shared instance + a 127.0.0.1 up-time host-port overlay → provisioner call → `RecordProvisioned` + `DB.LogEvent`. Reads (`list`/`show`) are lock-free snapshots. +- **Host reachability is a per-engine overlay, never a change to deterministic `generate` output.** Generalize `writeProvisionOverlay`: a per-engine `(purpose, portBase, containerPort)` registry, each publishing `127.0.0.1::` into `.devstack/shared/compose.provision.yaml`. The golden-asserted generate output stays byte-identical; \"no host ports by default\" still holds. (Today's Postgres overlay uses purpose `pg-provision`, base `45432`, container `5432`.) +- **Per-resource credential policy.** `predictable` (the Postgres dev-cred model: password = project name, nothing secret stored, attrs reach containers via the `env.import` resolver) **or** `generated` (random value via a `crypto/rand` helper in `internal/secrets` → pushed to a provider via the `Pusher`, injected as a valueless env key). Default is `predictable` for loopback dev creds; `generated` is opt-in per resource. +- **Never-recreate-a-stateful guard extends to resources.** A provisioner may create/drop a *resource* (database/bucket/queue) but **never** the shared container or its volume. Teardown is always explicit + confirmed. + +## CLI surface +Generic resource verbs (the escape hatch + the substrate spec 29's per-domain verbs delegate to). All respect `--json`/`--quiet` and the global flock. **These are net-new commands** (no `resource` group exists today; the only data-plane CLI today is the `provisionPhase` inside `up`). + +``` +devstack resource list [--project P] [--engine E] [--kind K] [--json] +devstack resource show [--engine E] [--project P] [--json] [--show-secrets] +devstack resource create [--project P] [--param k=v ...] [--credentials predictable|generated] +devstack resource rm [--engine E] [--project P] [--purge-data] [--yes] +devstack resource gc [--project P] [--engine E] [--kind K] [--prune] [--yes] [--json] +``` + +- Default `--project` is the current workspace project; `resource list`/`show` default to all engines. +- `resource create` is the engine-generic primitive; spec 29 adds ergonomic aliases (`devstack db create`, `devstack s3 mb`, `devstack queue add`) that resolve to the same provisioners. +- `resource rm --purge-data` calls `Provisioner.Drop` (destructive → TTY confirm or `--yes`); without `--purge-data` it only removes the ledger row (un-tracks, leaves bytes). +- `resource gc` is the **first CLI consumer** of `OrphanedProvisioned(active)` ([`ledger.go`](../../internal/state/ledger.go) line 313) — the query exists in the ledger today but nothing reaps it yet. (It is distinct from the existing `shared gc`, which ref-counts shared *containers*, not in-container resources.) The planned v1 `db gc` ([spec 15](15-db-snapshot-restore.md) / the unbuilt `db` domain group) becomes a `--engine postgres` alias of `resource gc` once both land. + +## The Provisioner interface family +Today only `provision.Postgres` exists, over the minimal `provision.Conn` (`Exec`/`Exists`). Generalize to a `Provisioner` the orchestrator depends on, with each engine keeping its own tiny seam underneath: + +```go +// internal/resource — the engine-agnostic contract the orchestrator + cobra +// commands depend on. One implementation per shared engine. +type Resource struct { + Engine string // == template provides: ; ledger 'engine' column + Kind string // database|role|user|bucket|lifecycle|queue|stream|topic + Name string // engine-level identifier (tenant-namespaced) + Owner string // owning project (provisioned.project) + Params map[string]any // kind-specific knobs (e.g. lifecycle: expireDays) + CredKind CredentialPolicy // predictable | generated +} + +type Attrs map[string]string // host/port/user/database/bucket/subject + a cred ref + +// Target is the resolved, host-reachable admin endpoint for ONE shared instance, +// produced by the overlay+ledger-port resolution (mirrors provision.DSN today). +type Target struct { + Instance string // shared service name (e.g. \"postgres\") + Host string // \"127.0.0.1\" + Port int // ledger-allocated published host port + AdminEnv map[string]string // root creds from the instance's params +} + +type Provisioner interface { + Engine() string // matches template provides: + Kinds() []string // the kinds it can create + Ensure(ctx context.Context, t Target, r Resource) (Attrs, error) // idempotent, existence-guarded + Drop(ctx context.Context, t Target, r Resource) error // teardown for gc / --purge-data + Preflight(ctx context.Context, t Target) error // tool present + version-compatible +} +``` + +`Ensure` is the generalization of `Postgres.EnsureProject` ([`internal/provision/provision.go`](../../internal/provision/provision.go)): **existence-guarded and idempotent** (the same guard discipline — `SELECT 1 FROM pg_roles …` before `CREATE ROLE`, because `CREATE DATABASE`/`CREATE ROLE` are **not** idempotent, [DECISIONS D8](../DECISIONS.md)). The engine table: + +| engine | kinds | underlying seam | tool | purpose / portBase / containerPort | +|---|---|---|---|---| +| `postgres` | `database`, `role`, `user` | `provision.Conn` (existing) | `pgx/v5` in-process | `pg-provision` / `45432` / `5432` | +| `redis` | `redis_index` (logical db), `acl_user` | `redis.Conn` (small seam) | pure-Go client | `redis-provision` / `46379` / `6379` | +| `minio` | `bucket`, `lifecycle`, `access_key` | `minio.Admin` | `aws-sdk-go-v2` S3 (data) + `mc`/`madmin` (IAM) | `minio-provision` / `49000` / `9000` | +| `localstack` | `bucket`, `queue`, `topic`, `stream`, `table` | `aws.Client` | `aws-sdk-go-v2` | `ls-provision` / `44566` / `4566` | +| `nats` | `stream`, `consumer`, `kv` | `nats.JS` | `nats.go` (pure-Go) | `nats-provision` / `44222` / `4222` | +| `kafka` | `topic`, `acl` | `kafka.Admin` | `franz-go` (pure-Go) or `rpk` | `kafka-provision` / `49092` / `9092` | + +> **Net-new engines.** `postgres`/`redis`/`minio` ship as built-in engine templates today; `localstack`/`nats`/`kafka` have **zero** references anywhere in the tree and are 100% net-new (template + provisioner + doctor probe), landed in the Full scope below. + +Postgres' new `user`/`grant` kinds extend `EnsureProject` (a project may own >1 login role); Redis keeps `AllocateRedisIndex` ([`ledger.go`](../../internal/state/ledger.go) line 341) as its `redis_index` provisioner — the one non-Postgres allocator that already ships. Each seam ships with a mock so race/unit tests run without a live engine (the `internal/docker` `MockClient` discipline; `provision`'s `mockConn` is the existing model). + +## The declarative complement — `resources:` in `devstack.yaml` +A project declares the resources it needs additively; `up` provisions them. This is the idiomatic ARCHITECTURE §7.4 additive block — **deterministic config, idempotent runtime**. + +```yaml +# devstack.yaml (additive; existing fields unchanged) +apiVersion: devstack/v1 +kind: Project +name: acme +services: + api: + template: node.vite + uses: [workspace.shared.postgres, workspace.shared.minio] + env: + import: + - from: workspace.shared.postgres # password flows here, NEVER inline ${ref} + vars: [PGPASSWORD] +resources: + - uses: workspace.shared.postgres + kind: database + name: acme # default: project name + credentials: predictable # dev cred; password = project name + - uses: workspace.shared.minio + kind: bucket + name: acme-uploads + credentials: generated # random key → Pusher → provider → env + - uses: workspace.shared.minio + kind: lifecycle + name: acme-uploads + params: { expireDays: 7 } +``` + +Config-model extension (validated by the existing custom cross-ref resolver, [`internal/config/validate.go`](../../internal/config/validate.go)): + +```go +// internal/config/model.go — additive on Project +type ResourceDecl struct { + Uses string `yaml:\"uses\" validate:\"required\"` // workspace.shared. + Kind string `yaml:\"kind\" validate:\"required\"` // database|user|bucket|lifecycle|queue|stream|topic + Name string `yaml:\"name\"` // default: project name + Params map[string]any `yaml:\"params\"` + Credentials string `yaml:\"credentials\" validate:\"omitempty,oneof=predictable generated\"` +} +// Project gains: Resources []ResourceDecl `yaml:\"resources\" validate:\"dive\"` +``` + +The resolver checks each `uses:` points at a declared shared instance (the `ParseRef`/`RefShared` path already used for `Service.Uses`), that `kind` is one the target engine's provisioner supports, and that no two resources collide on `(engine, name)`. Lists **replace** on overlay merge unless `$merge: append` ([spec 02](02-templating-and-generation.md)) — so a profile/extends layer overrides the resource set wholesale by default. + +## Behavior +**Imperative `resource create`** (the canonical pattern, identical in shape to `provisionPhase`): +1. **Resolve the model** — `buildManager` ([`internal/cli/shared.go`](../../internal/cli/shared.go)) assembles model + docker + ledger + lock + manager; resolve `` → the live shared instance `(engine, major)` and confirm it is healthy (resources can only be created on a running engine; if down, instruct `devstack up`). +2. **Ensure host reachability** — `Manager.FreeHostPort(ctx, SharedAlias(inst), , )` (self-locking, idempotent — returns the port a prior overlay already allocated). Write/refresh the per-engine overlay `127.0.0.1::` via the generalized `writeProvisionOverlay`, then `docker compose -p devstack-shared up -d ` to apply it (idempotent; no recreate). +3. **Take the flock** — `lock.WithLock(ctx, LockPath, func() error { … })`. Inside it only: +4. **Provision** — build the `Target` (admin creds from the instance `Params`, e.g. `rootUser`/`rootPassword`), call `prov.Ensure(ctx, target, resource)`. Existence-guarded → safe to re-run. +5. **Record** — `DB.RecordProvisioned(project, kind, name)` and `DB.LogEvent(\"provision\", project, \" on \"+SharedAlias(inst))`. (For `credentials: generated`: generate a random value via `crypto/rand` → `Pusher.Push([]SecretEntry{…})` to the provider **before** recording, so the ledger never references a secret that isn't stored.) +6. **Release lock, surface attributes** — outside the lock, resolve and print the connection facts (`--json`/`--quiet`; secrets masked unless `--show-secrets`). + +**Declarative at `up`** — a new `resources` saga phase, inserted **after** the existing `provision` phase (which it subsumes for non-Postgres engines) and before the per-project `compose-up`/`hooks` phases (the wired order today is `preflight → network → generate → secrets → trust → shared → provision → compose-up/hooks`, [`internal/orchestrate/up.go`](../../internal/orchestrate/up.go)): +7. Collect every active project's `resources:` entries whose target instance is in the up set; group by instance; resolve each instance's host port (step 2, shared with the existing provision phase so one overlay covers all). +8. Under the flock, for each entry call the engine's `Provisioner.Ensure` and `RecordProvisioned` + `LogEvent`, exactly as steps 4–5. Idempotent and existence-guarded → re-running `up` is a no-op on already-provisioned resources. Compensation is intentionally empty (provisioned resources are *data* and survive a failed `up`, like the Postgres phase). +9. The existing `provisionPhase` becomes the `postgres` provisioner under this generalized phase — its `provTargets`/`pgInstances` logic is the per-engine selector pattern the other engines copy. + +**Reads** (`resource list`/`show`) are lock-free: `AllProvisioned` / `ProvisionedFor(project)`, joined with the live model to re-derive attributes; `--show-secrets` additionally fetches the stored value from the provider for diagnostics only. + +## Credential / connection-string surfacing +Two policies, chosen per resource, never plaintext on disk either way: + +- **`predictable` (dev-cred model).** The Postgres pattern: the credential is derived (`password = project name`), nothing secret is generated or stored, and the consuming container receives it through the `env.import` resolver as a valueless per-service env key filled at compose-up time. The non-secret attrs (`host` → `shared-` DNS alias, `port` → template `defaultPort`, `user`/`database` → the **consumer project name**) resolve inline through `sharedAttr` in [`internal/generate/resolver.go`](../../internal/generate/resolver.go). Documented DSN: `postgres://:@shared-postgres:5432/`. +- **`generated` (secret-via-Pusher).** Random value via a `crypto/rand` generator in `internal/secrets` (a **new** helper — the existing `GenerateAgeKey` in `keygen.go` is age/SOPS key material, not a credential generator) → pushed to the configured provider (`aws-sm`/`aws-ssm`/`infisical`, the `Pusher` interface `Push(ctx, []SecretEntry)` in [`internal/secrets/pusher.go`](../../internal/secrets/pusher.go), optionally SOPS-encrypted) → injected as a **valueless** `environment: [NAME]` key whose value arrives via `exec.Cmd.Env`. A CI test asserts no generated file contains the value (the §7.5 secrets coupling). + +In **both** cases the `${ref}` resolver continues to **reject** secret attributes (`password`/`secretkey`/`secret`/`token` — the `secretAttrs` map in `resolver.go`) as inline refs — they exist only as `env.import` flows. `resource show --json` emits the non-secret attrs + a redacted credential marker; `--show-secrets` is the only way to print the value and is documented as diagnostics-only. + +## Lifecycle & reclamation +- **`resource gc`** is the generic orphan reaper (the planned `db gc` will delegate to it): `OrphanedProvisioned(active)` ([`ledger.go`](../../internal/state/ledger.go) line 313) returns rows whose owner project is no longer in the workspace; the reaper groups by `(engine, kind)` and calls `Provisioner.Drop`, then `RemoveProvisionedForProject`. Destructive → explicit confirm or `--yes`, and a `gc.drop` `event_log` row per resource. Once the `db` domain group lands ([spec 15](15-db-snapshot-restore.md), currently unbuilt), `db gc` becomes a domain-scoped alias (`--engine postgres`). This is **distinct from today's `shared gc`**, which ref-counts shared *containers*, not in-container resources. +- **Never-recreate-stateful holds.** `Drop` removes the *database/bucket/queue/topic*, **never** the shared container or volume. A drop must not bounce the engine other tenants are live on (terminate only the dropped tenant's sessions, e.g. `pg_terminate_backend WHERE datname=` before `DROP DATABASE`). +- **`workspace destroy --purge-data`** (a **new** flag). `workspace destroy` today is strictly data-preserving — it carries only `--yes`, and [`internal/cli/destroy.go`](../../internal/cli/destroy.go) explicitly defers named-volume / provisioned-DB removal to `uninstall`/the db group. `--purge-data` adds the opt-in destructive path: before tearing down the project stack it enumerates `ProvisionedFor(project)`, calls `Provisioner.Drop` for each (confirm-gated), then `RemoveProvisionedForProject(project)` and `ReleasePortsFor` the overlay ports ([`ledger.go`](../../internal/state/ledger.go) line 278). Without `--purge-data`, destroy keeps its current contract: it leaves the resources (and their bytes) and only removes the project's containers — the data survives a re-clone. + +## Verified constraints / gotchas +- **`CREATE DATABASE`/`CREATE ROLE` are not idempotent and can't run in a transaction** — the naive \"just run the DDL\" loses on the second invocation and on concurrent `up`s. Every provisioner's `Ensure` must be existence-guarded (`Exists` before `Exec`) exactly like `Postgres.EnsureProject`, and the SQL must run **unwrapped** ([DECISIONS D8](../DECISIONS.md)). +- **Don't reach the engine over the bridge IP or a default-published port — there is none.** Shared engines publish **no** host port by default ([spec 03](03-workspaces-and-shared-services.md)), and bridge IPs aren't host-routable on Docker Desktop/WSL2. Host-side tooling (pgx, the S3 SDK, `mc`, `nats`, `rpk`) runs **from the host** and must go through a **127.0.0.1 up-time overlay** with a **ledger-allocated** port — replicate `writeProvisionOverlay` per engine with its own `purpose`/`portBase`/`containerPort`, never hardcode `5432`. +- **`FreeHostPort`/`AllocatePort` must self-lock and be idempotent per `(owner, purpose)`** — re-running a command must return the *same* port (`PortFor` lookup before allocation, [`ledger.go`](../../internal/state/ledger.go) line 232), or every invocation leaks a new mapping and the overlay diverges. Bind-tests are advisory (TOCTOU); union with Docker-published ports ([spec 03](03-workspaces-and-shared-services.md) port allocation). +- **The flock wraps the ledger CRUD + the provisioner DDL, not the whole command** — but a *long* provisioning op (e.g. seeding a large bucket) must run **outside** the lock, like long hooks and `pg_restore` ([spec 15](15-db-snapshot-restore.md)); holding the flock for it serializes every other terminal. Hold it only around `Exists`/`Exec`/`RecordProvisioned`/`LogEvent`. +- **Tenant names must be sanitized per engine** — `pgIdent` maps `-`→`_` and quotes; S3 bucket names are globally unique per instance, lowercase, DNS-label-constrained; Kafka topics and NATS subjects have their own charsets. A naive \"use the project name verbatim\" breaks on hyphenated projects. Each provisioner owns its identifier-mapping (and it must be **stable** across runs so the same project always resolves to the same resource). +- **`kind` is free-text, but the set is still validated** — adding `bucket`/`queue` needs no migration, yet the config resolver and `resource create` must reject a `kind` the target engine's provisioner doesn't list (`Provisioner.Kinds()`), or you record a ledger row nothing can ever drop. +- **Secret attributes never become inline refs** — the resolver rejects `${ref:…password}` (see `secretAttrs` in `resolver.go`); a generated credential reaches a container **only** through an `env.import` valueless key. A CI test must assert no generated file contains any secret value (the existing §7.5 assertion, extended to every engine). +- **External engine tools break the single-static-binary promise for their verbs only** — prefer pure-Go SDKs (`pgx`, `aws-sdk-go-v2`, `nats.go`, `franz-go`) so `up` and the common path stay self-contained (`CGO_ENABLED=0`); gate `mc`/`madmin`/`rpk` behind `info`-level `doctor` probes so their absence degrades one engine's resource verbs, never blocks `up` (the `mkcert`/`cloudflared` posture, [DECISIONS D11/D12](../DECISIONS.md)). +- **No daemon (v1 model holds)** — resource provisioning is explicit (`resource create`) or `up`-time (declarative). There is no background reconciler creating resources; drift is reported on the next command and reclaimed only by explicit `gc`. + +## Acceptance criteria +- [ ] A new `kind` (`bucket`/`queue`/`stream`/`topic`/`user`/`lifecycle`) is recorded in `provisioned` and reclaimed by `resource gc` with **zero** schema migrations. +- [ ] `resource create minio bucket acme-uploads --project acme` creates the bucket idempotently (second run is a no-op), records the ledger row, logs an `event_log` entry, and runs the create inside the flock with the bucket op (if long) outside it. +- [ ] Provisioning project A's resources can never read or mutate project B's on the same shared engine (tenant-scoping CI assertion across all engines, mirroring the Postgres role isolation). +- [ ] The declarative `resources:` block in `devstack.yaml` is provisioned by the `up` saga phase; re-running `up` provisions nothing new (idempotent), and removing an entry does **not** drop the resource (Q-RESOURCE-DRIFT). +- [ ] `generate` output is **byte-identical** with and without a `resources:` block and with the per-engine host-port overlay present (overlay is an up-time artifact, not generated output; determinism CI green). +- [ ] A `credentials: generated` resource pushes its value to the provider (`Pusher.Push`), injects it as a **valueless** env key, and **no** generated file contains the value (CI assertion); `${ref:…secret}` for that resource is a hard resolver error. +- [ ] Two concurrent `resource create` invocations (two terminals) → no `database is locked`, no duplicate port assignment, no `already exists` crash (flock + existence-guarded `Ensure`). +- [ ] `resource gc` after removing a project reclaims its orphaned resources across engines only after explicit confirmation, calling each engine's `Drop`, and never recreates or bounces the shared container. +- [ ] `workspace destroy --purge-data` (the new flag) drops every `ProvisionedFor(project)` resource (confirm-gated), removes the rows, and releases the overlay ports; without `--purge-data` the resources survive (the command's current data-preserving contract is unchanged). +- [ ] `resource list --json` and `resource show --json` emit the documented attribute schema with secrets redacted; `--show-secrets` is the only path that prints a value. + +## Dependencies / consumers +Consumes `internal/provision` (the `Conn`/`EnsureProject` pattern, reused verbatim for Postgres and as the template for every new engine seam), `internal/state` + `internal/lock` (the `provisioned` table + flock — every row write and DDL is locked; reads are lock-free), `internal/generate` (`SharedAlias`, the `${ref}`/`sharedAttr` resolver, the generalized `writeProvisionOverlay`), `internal/docker` (read-only enumeration + the Compose driver to apply the overlay), `internal/config` (the additive `resources:` block + the cross-ref resolver), `internal/secrets` (the `Pusher` + a new `crypto/rand` cred generator + SOPS for `generated` credentials), and `internal/orchestrate` (the `resources` saga phase, generalizing `provisionPhase`). Consumed by `internal/cli` (`resource list|show|create|rm|gc`, and the new `workspace destroy --purge-data` flag), and — crucially — by **spec 28** (resource templates: the `provides:`/`exports:`/`kinds:` template surface each provisioner reads) and **spec 29** (per-domain verbs `db`/`s3`/`queue`/`stream`/`topic` that delegate to these provisioners; the planned `db gc` aliases `resource gc --engine postgres`). **Thinner v2 (~3w):** the `Resource` model + `Provisioner` interface + Postgres (`database`/`user`) and MinIO (`bucket`/`lifecycle`) provisioners + the generic `resource` verbs + the declarative phase. **Full (~6w):** adds Redis ACL, the net-new LocalStack/NATS/Kafka engine templates + provisioners, the `generated` credential path across all engines, and `resource gc` parity with `workspace destroy --purge-data`. + +## Open questions +[Q-DAEMON](../OPEN-QUESTIONS.md) (no daemon → all resource ops are explicit or up-time; no background reconcile). **New:** **Q-RESOURCE-SHARING** — can two projects share one resource (a bucket A writes, B reads)? *Recommendation:* v2 stays strictly single-owner (the tenant-scoping invariant); cross-project access is a future explicit `grants:` block, not implicit. **Decision:** single-owner in v2; defer shared-resource grants. **New:** **Q-RESOURCE-DRIFT** — when a `resources:` entry is deleted from `devstack.yaml`, should `up` drop the resource? *Recommendation:* never auto-drop (silent data loss); reconcile reports drift, `resource gc`/`rm --purge-data` reclaim explicitly. **Decision:** declarative removal is non-destructive; teardown is always explicit + confirmed. **New:** **Q-MINIO-DRIVER** — pure-Go `aws-sdk-go-v2` S3 vs shelling `mc`/`madmin` for IAM. *Recommendation:* SDK for bucket/object/lifecycle (keeps the static binary), `mc`/`madmin` only for admin IAM scoped-key policy, both behind one `internal/minio` seam with `info`-level doctor probes. **Decision:** prefer the pure-Go SDK; external `mc` is an opt-in admin-only fallback. \ No newline at end of file diff --git a/docs/specs/28-cloud-engine-templates.md b/docs/specs/28-cloud-engine-templates.md new file mode 100644 index 0000000..94ebde7 --- /dev/null +++ b/docs/specs/28-cloud-engine-templates.md @@ -0,0 +1,242 @@ +# Spec 28 — Local-cloud shared-engine templates (LocalStack, NATS, Kafka, RabbitMQ, ministack) + +**Module:** `templates/*` (+ `internal/scaffold`, `internal/generate`, `internal/workspace`) · **Milestone:** v2 · **Effort:** ~2.5w (new feature #18 — local-cloud engines; needs a FEATURES.md entry) + +> Numbering note: specs currently stop at 25 and features at 17. This spec claims **28** and forward-references its imperative sibling [spec 29](29-resource-commands.md); specs **26/27** are unallocated gaps that must be reserved (or this pair renumbered to 26/27) before merge. FEATURES.md has no #18/#19 — this spec adds **#18** and the sibling adds **#19**. + +## Purpose +Extend the shared-engine set beyond `postgres`/`redis`/`minio` so a workspace can stand up a **local cloud** — AWS emulation (LocalStack: S3/SQS/SNS/Kinesis/DynamoDB/Lambda), event streaming (NATS JetStream, Kafka), and AMQP (RabbitMQ) — all as warm, ref-counted, tenant-isolated instances on the one `devstack_shared` network, instead of a duplicate broker per repo. Each engine is **net-new** (zero existing references anywhere in the tree) and ships as exactly one `templates//template.yaml` declaring `provides:`/`exports:`/`defaultPort:`/`params:`/`service:` — the same shared-engine contract the three built-ins already satisfy. This spec defines **only the engine templates and their capability/version/host-port/healthcheck/tenant-scoping model**; the imperative `aws`/`nats`/`kafka`/`amqp` resource commands that create buckets/queues/streams *inside* these engines are the sibling [spec 29](29-resource-commands.md), and the data lifecycle (`db snapshot/restore`) is [spec 15](15-db-snapshot-restore.md). Templates land first because [spec 29](29-resource-commands.md) has nothing to provision against until the engines exist. + +## Decisions +- **One template per engine, capability-keyed.** `provides:` is the ledger `engine` column ([spec 08](08-state-locking-and-lifecycle.md)); instances are keyed `(engine, major)` exactly like `shared-postgres-18` ([spec 03](03-workspaces-and-shared-services.md) §version-conflict). New capabilities: `aws` (LocalStack), `nats`, `kafka`, `amqp` (RabbitMQ). The DNS alias is `shared-` (`shared-localstack`, `shared-nats`, `shared-kafka`, `shared-rabbitmq`) via the existing `sharedAlias`/`sharedAttr` resolver (`internal/generate/resolver.go`). +- **`exports:` carries only non-secret attrs** (`host`, `port`, plus engine extras like `endpoint`, `region`, `monitorPort`/`adminPort`/`mgmtPort`). Anything secret (`password`, `secretkey`, `secret`, `token`) is **rejected as an inline `${ref}`** by `secretAttrs` (`resolver.go:16`) and must flow through `env.import` as valueless per-service env keys filled at compose-up ([spec 04](04-secrets.md) §7.5 coupling) — no plaintext on disk, asserted by the existing determinism/secret CI test. +- **Resolver extension is part of this spec.** `host`/`port`/`endpoint`/`region` resolve from data the resolver already has (the DNS alias and the instance's single in-network `defaultPort` in `sharedPort map[string]int`, plus the `region` param). But `monitorPort`/`adminPort`/`mgmtPort` are **secondary** container ports (8222/9644/15672), which the resolver does **not** track today — it carries only one port per shared service. Exporting them requires a small per-template **export-attr → port lookup** (a static map from the template's declared secondary ports), not a literal hardcode in `sharedAttr`. This is the one non-trivial `internal/generate` change; see Open questions. +- **Kafka = Redpanda, not Apache Kafka.** Redpanda is a single C++ static-binary, Kafka-API-compatible broker with built-in schema registry and no JVM/ZooKeeper/KRaft-quorum ceremony — dramatically lighter and faster to a healthy state for local dev. We still `provides: kafka` (the capability is the wire protocol, not the implementation) so `${ref:workspace.shared.kafka}` and the [spec 29](29-resource-commands.md) `rpk`-driven commands stay implementation-agnostic. (See gotchas for why plain `apache/kafka` KRaft is the worse local default.) +- **Host-port policy per engine follows [spec 03](03-workspaces-and-shared-services.md): default no published port** (DNS over `devstack_shared`). A `127.0.0.1`-only host port is published **only** when a host-side tool must reach the engine — and that publish is an **up-time compose overlay** (`writeProvisionOverlay` → `.devstack/shared/compose.provision.yaml`, the pattern provisioning already uses for `pg-provision` port 45432), never the deterministic `generate` output. Each engine that needs host reachability gets its own `FreeHostPort` *purpose* + base (see table). Note the live signature is `FreeHostPort(ctx, owner, purpose, base)` (owner = the shared alias, e.g. `shared-localstack`), self-locking inside the flock — same call shape as `internal/orchestrate/provision.go`. +- **LocalStack edition = community** (`localstack/localstack`, Apache-2.0), services selected via the `SERVICES` env param. Pro/`localstack-pro` is owner-opt-in via the `image`/`authToken` params, never default (it needs a paid token through `env.import`). +- **Tenant scoping is per-engine and prefix-based** where the engine namespace is global. Bucket names (LocalStack S3 / MinIO), Kinesis stream names, DynamoDB table names, SQS/SNS names, and Kafka topics are **flat global namespaces inside one instance** — every resource [spec 29](29-resource-commands.md) creates is **prefixed by the consumer project** (`-`) and recorded in the `provisioned(ctx, project, kind, name)` ledger via `RecordProvisioned` (`kind` is free-text — `bucket`/`queue`/`stream`/`topic`/`table`/`vhost` need **no migration**, joining the existing `db`/`role`/`bucket`/`redis_index` kinds). Project A can never name-collide with or reap project B's resources. +- **`ministack` is a second AWS-emulation engine (owner-decided).** It is the [ministack.org](https://ministack.org/) container image — a lighter LocalStack alternative — authored as a normal `templates/ministack/template.yaml` declaring `provides: aws` (same capability as LocalStack, so the [spec 29](29-resource-commands.md) `aws`/`s3`/`queue` verbs are backend-agnostic). A workspace picks one AWS-emulation engine by template name; both key the ledger as `engine=aws`. Its exact image tag / edge port / `SERVICES` knob / health endpoint must be confirmed against ministack.org before authoring (zero references in the tree today). +- **No new pure-Go runtime SDK.** `pgx/v5` stays the only in-process runtime SDK. Every new engine tool (`awslocal`/`aws`, `nats`, `rpk`, `rabbitmqadmin`) is an **external binary shelled out behind an `internal/` interface** with a mock and a `CmdError(cmd+code+stderr)` — the `docker`/`git`/[spec 15](15-db-snapshot-restore.md) `Dumper` discipline — and is an `info`-level `doctor` probe (absence degrades only the matching [spec 29](29-resource-commands.md) verbs, never blocks `up`). The probes themselves are introduced by [spec 29](29-resource-commands.md) alongside the verbs that use them; the templates here add **zero** Go deps and leave the `CGO_ENABLED=0` static binary untouched. + +### Capability / version / host-port matrix + +| Template | `provides` | DNS alias | `defaultPort` (in-network) | Host port? (purpose / base) | Stateful volume | Healthcheck | +|---|---|---|---|---|---|---| +| `localstack` | `aws` | `shared-localstack` | 4566 (edge) | **yes** — host AWS SDK/`aws` CLI (`localstack-host` / 44566) | `localstackdata:/var/lib/localstack` | `curl -sf localhost:4566/_localstack/health \| grep running` | +| `nats` | `nats` | `shared-nats` | 4222 (client); 8222 monitor | **yes** for host `nats` CLI (`nats-host` / 44222); monitor 8222 host-opt | `natsdata:/data` (JetStream) | `wget -qO- localhost:8222/healthz` | +| `kafka` (Redpanda) | `kafka` | `shared-kafka` | 9092 (Kafka API); 9644 admin | **yes** for host `rpk`/clients (`kafka-host` / 49092) | `kafkadata:/var/lib/redpanda/data` | `rpk cluster health -x admin.hosts=localhost:9644` | +| `rabbitmq` | `amqp` | `shared-rabbitmq` | 5672 (AMQP); 15672 mgmt UI | mgmt UI host-opt (`rabbitmq-mgmt` / 45672) | `rabbitmqdata:/var/lib/rabbitmq` | `rabbitmq-diagnostics -q ping` | + +> The advertised-listener subtlety (Kafka/Redpanda, NATS routes) is why these need **two listeners** — see gotchas. Host ports are `127.0.0.1`-bound and ledger-allocated **inside the flock** ([spec 03](03-workspaces-and-shared-services.md) §port-allocation) via `FreeHostPort(ctx, owner, purpose, base)`, unioned with Docker-published bindings. + +## LocalStack — `templates/localstack/template.yaml` +```yaml +schemaVersion: 1 +description: "Shared LocalStack AWS-emulation engine, reached over the shared network at shared-localstack:4566." +provides: aws +exports: [host, port, endpoint, region] +defaultPort: 4566 +params: + image: + type: string + default: "localstack/localstack:3" + description: "LocalStack community image; pin a major for (engine,major) ledger keying." + services: + type: string + default: "s3,sqs,sns,kinesis,dynamodb" + description: "Comma list for the SERVICES env (lazy-loads only these for fast, light startup)." + region: + type: string + default: "us-east-1" +service: + image: "[[ .params.image ]]" + restart: unless-stopped + environment: + SERVICES: "[[ .params.services ]]" + DEBUG: "0" + AWS_DEFAULT_REGION: "[[ .params.region ]]" + # Persist across restarts (community persistence is best-effort; see gotchas). + PERSISTENCE: "1" + volumes: + - "localstackdata:/var/lib/localstack" + healthcheck: + # /_localstack/health returns per-service readiness; gate on the edge being up. + test: ["CMD-SHELL", "curl -sf http://localhost:4566/_localstack/health | grep -q running"] + interval: 10s + timeout: 5s + retries: 8 + start_period: 20s +volumes: + localstackdata: {} +``` +- **In-network:** a consumer sets `AWS_ENDPOINT_URL=http://shared-localstack:4566` (resolved via `${ref:workspace.shared.localstack.endpoint}` → `http://shared-localstack:4566`; `region` → the param). **Host-side** ([spec 29](29-resource-commands.md) `aws`/`awslocal`): the `localstack-host`/44566 overlay publishes `127.0.0.1:44566:4566`, and the resource command targets `--endpoint-url http://127.0.0.1:44566`. Credentials for emulation are the canonical dummy pair (`test`/`test`) — **not secret**, so they may be plain `environment:` on the consumer, but a generated-secret policy can still route them via `env.import`. +- **`endpoint` and `region` are new export attrs**, both non-secret and resolvable from data the resolver already has: `sharedAttr` gains `case "endpoint"` → `"http://" + sharedAlias(name) + ":" + sharedPort[name]` and `case "region"` → the instance's `region` param. + +## NATS (JetStream) — `templates/nats/template.yaml` +```yaml +schemaVersion: 1 +description: "Shared NATS engine with JetStream, reached over the shared network at shared-nats:4222." +provides: nats +exports: [host, port, monitorPort] +defaultPort: 4222 +params: + version: + type: string + default: "2" + description: "NATS major version image tag (the (engine,major) key)." +service: + image: "nats:[[ .params.version ]]" + restart: unless-stopped + # -js enables JetStream (streams/queues/KV); -m 8222 enables the monitoring port. + command: ["-js", "-sd", "/data", "-m", "8222"] + volumes: + - "natsdata:/data" + healthcheck: + # The container ships no curl; use the bundled wget against the /healthz monitor route. + test: ["CMD-SHELL", "wget -qO- http://localhost:8222/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + natsdata: {} +``` +- JetStream gives durable streams (event sourcing) and queue groups (work queues) — the two patterns [spec 29](29-resource-commands.md) `nats stream/consumer` provisions, prefixed `-`. Monitoring on 8222 is in-network by default (`monitorPort` export, which needs the secondary-port resolver lookup) and host-published only on request. + +## Kafka (Redpanda) — `templates/kafka/template.yaml` +```yaml +schemaVersion: 1 +description: "Shared Kafka-compatible broker (Redpanda), reached over the shared network at shared-kafka:9092." +provides: kafka +exports: [host, port, adminPort] +defaultPort: 9092 +params: + image: + type: string + default: "redpandadata/redpanda:v24.2.7" + description: "Single-binary Kafka-API broker; pin a version for (engine,major) keying." +service: + image: "[[ .params.image ]]" + restart: unless-stopped + command: + - "redpanda" + - "start" + - "--mode=dev-container" # single-node, no quorum ceremony + - "--smp=1" + - "--default-log-level=warn" + # TWO advertised listeners: in-network clients use the DNS alias; host tools + # use 127.0.0.1. A single advertised listener is the #1 Kafka-local footgun. + - "--kafka-addr=internal://0.0.0.0:9092,external://0.0.0.0:19092" + - "--advertise-kafka-addr=internal://shared-kafka:9092,external://127.0.0.1:49092" + volumes: + - "kafkadata:/var/lib/redpanda/data" + healthcheck: + test: ["CMD-SHELL", "rpk cluster health -x admin.hosts=localhost:9644 | grep -q 'Healthy:.*true'"] + interval: 10s + timeout: 5s + retries: 8 + start_period: 15s +volumes: + kafkadata: {} +``` +- **In-network** clients connect to `shared-kafka:9092` (the `internal` advertised listener). **Host** tools/`rpk` reach `127.0.0.1:49092` via the `external` listener — the `kafka-host`/49092 overlay publishes `127.0.0.1:49092:19092`. The two-listener split is mandatory: a broker advertises the address it tells clients to reconnect on after bootstrap, so one listener can satisfy either in-network DNS **or** host loopback, never both. +- `rpk` is the [spec 29](29-resource-commands.md) host tool for `kafka topic/acl`; topics are prefixed `-` and ledger-recorded `kind=topic`. + +## RabbitMQ — `templates/rabbitmq/template.yaml` +```yaml +schemaVersion: 1 +description: "Shared RabbitMQ (AMQP) engine with the management UI, reached at shared-rabbitmq:5672." +provides: amqp +exports: [host, port, mgmtPort] +defaultPort: 5672 +params: + version: + type: string + default: "3" + description: "RabbitMQ major (the (engine,major) key); the -management tag adds the UI." + user: + type: string + default: devstack +service: + image: "rabbitmq:[[ .params.version ]]-management" + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: "[[ .params.user ]]" + # Password is secret → injected via env.import at compose-up, never inline. + RABBITMQ_DEFAULT_PASS: + volumes: + - "rabbitmqdata:/var/lib/rabbitmq" + healthcheck: + test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s +volumes: + rabbitmqdata: {} +``` +- `RABBITMQ_DEFAULT_PASS` is emitted as a **valueless per-service env key** (the `environment: [NAME]` form) and filled from the secrets provider at compose-up — the [spec 04](04-secrets.md) coupling. The management UI (15672) is in-network by default (`mgmtPort` export), host-published only on request (`rabbitmq-mgmt`/45672). Per-project isolation = a project-prefixed vhost (``) + scoped user, provisioned by [spec 29](29-resource-commands.md) `amqp vhost`, `kind=vhost`. + +## `ministack` — a second AWS-emulation engine (owner decision: it's an image, like LocalStack) +**Decided:** `ministack` is the [ministack.org](https://ministack.org/) container image — a real AWS-local-emulation runtime, a lighter sibling/alternative to LocalStack. So it is a **normal shared engine template**, NOT a preset: `templates/ministack/template.yaml` declaring `provides: aws` (the *same* capability as LocalStack, so a project's `uses: workspace.shared.` and the [spec 29](29-resource-commands.md) `aws`/`s3`/`queue` verbs are backend-agnostic — they target whichever AWS-emulation engine the workspace ran). A workspace picks **one** AWS-emulation engine (LocalStack *or* ministack) by template name; both key the ledger as `engine=aws` so only one `(aws, major)` instance is warm. +```yaml +# templates/ministack/template.yaml (illustrative — confirm image tag/edge port/SERVICES knob against ministack.org docs) +schemaVersion: 1 +description: "ministack — local AWS emulation (S3/SQS/SNS/…), a lighter LocalStack alternative." +provides: aws +exports: [endpoint, region] # same export contract as localstack (host-side --endpoint-url + region) +defaultPort: 4566 # CONFIRM: ministack's edge/gateway port +params: + image: { type: string, default: "ministack/ministack:latest" } # CONFIRM exact repo/tag + services: { type: string, default: "s3,sqs,sns" } # CONFIRM the service-selection env/knob +service: + image: "[[ .params.image ]]" + restart: unless-stopped + environment: { SERVICES: "[[ .params.services ]]" } # CONFIRM env var name + healthcheck: { test: ["CMD","curl","-fsS","http://localhost:4566/_localstack/health"], interval: 10s, timeout: 5s, retries: 10 } # CONFIRM health endpoint +``` +Because the AWS API surface is what matters, `ministack` reuses LocalStack's exact `endpoint`/`region` export attrs, the same `127.0.0.1` host-port overlay (own `FreeHostPort` purpose `aws-host`/base shared with localstack since only one is active), and the same project-prefixed bucket/queue naming for tenant isolation. **The exact image tag, edge port, service-selection knob, and health endpoint must be confirmed against ministack.org before this template is authored** (the recon found zero references anywhere; these are placeholders). + +## Behavior +1. **Author/scaffold.** New built-in cloud engines are added in-tree under `templates//template.yaml` (the `go:embed` set). The user-facing authoring path is `template new --kind engine --name --provides --port --exports host,port,...` ([spec 23](23-template-authoring.md)); by default `template new` writes into the `$DEVSTACK_HOME/templates` **store** (pass `--dir templates` to seed an in-tree built-in). `internal/scaffold` rejects an empty `provides:`, so every shared engine here declares `provides:`+`defaultPort:`. Custom store copies under `$DEVSTACK_HOME/templates//` override the embedded built-in by name (the store chain ahead of `go:embed`). +2. **Reference resolution.** A consumer's `uses:`/`${ref:workspace.shared..}` resolves through `graphResolver.sharedAttr` (`resolver.go:82`): `host`→`shared-`, `port`→`defaultPort`, plus the new non-secret `endpoint`/`region` (from alias+defaultPort+param) and `monitorPort`/`adminPort`/`mgmtPort` (from the new export-attr→secondary-port lookup). Any secret attr is rejected → must go through `env.import`. +3. **Generate (deterministic).** The compose model is built programmatically and validated by `compose-go/v2`; the engine joins `devstack_shared` with its `shared-` alias; **no host port** appears in generated output. `writeIfChanged` + SHA-256 rebuild hash, byte-identical (CI determinism lane). +4. **Ledger keying.** On `up`, the engine registers as `shared--` keyed `(engine, major)` — `shared-postgres-18` style. A workspace pinning two majors of one engine gets two instances, ref-counted independently. +5. **Up saga (unchanged order).** `preflight → network → generate → secrets → trust → shared(health-gated) → provision(if targets>0) → hooks` ([spec 09](09-orchestration-and-onboarding.md)). The new engines are ordinary shared services in the `shared` phase; their healthchecks gate readiness ([spec 10](10-health-readiness-and-ordering.md)). **No new saga phase** is added by this spec. +6. **Host-reachability overlay (only when a host tool needs it).** When a host tool ([spec 29](29-resource-commands.md) resource verb, or an explicit host-port request) needs the engine reachable from the host, `FreeHostPort(ctx, sharedAlias(inst), "-host", base)` allocates **inside the flock**, and an up-time overlay `.devstack/shared/compose.provision.yaml` (written by `writeProvisionOverlay`) publishes `127.0.0.1::` — leaving deterministic `generate` output untouched, exactly like `pg-provision` (45432). Because the resource commands are **standalone cobra commands** that mirror the provisionPhase body (lock → host-port overlay → engine tool → ledger → `LogEvent`), not new saga phases, they own writing/refreshing this overlay outside `up` too. +7. **Tenant-scoped provisioning** is [spec 29](29-resource-commands.md): every resource is created `-`-prefixed via the engine's external tool behind an `internal/` interface, recorded `RecordProvisioned(ctx, project, kind, name)` under the flock, and the *ledger rows* are reaped by `db gc`'s `OrphanedProvisioned(active)` ([spec 13](13-doctor-diagnostics-and-teardown.md)) when the project is removed. (Reaping the row does not by itself delete the bucket/topic in the engine — the actual engine-side teardown is the [spec 29](29-resource-commands.md) tool's job, driven off the same rows.) + +## Verified constraints / gotchas +- **Kafka: do NOT default to `apache/kafka` KRaft.** Plain Apache Kafka needs explicit `KAFKA_NODE_ID`/`CONTROLLER_QUORUM_VOTERS`/`PROCESS_ROLES` plus a `cluster-id` and a `kafka-storage format` init step before it will start as a single node — a long, fragile, JVM-heavy boot. **Redpanda `--mode=dev-container`** is a single static binary that is healthy in seconds with no quorum config. We keep `provides: kafka` (capability = wire protocol) so consumers/[spec 29](29-resource-commands.md) are implementation-agnostic. +- **Advertised listeners must be split (Kafka & NATS routes).** A broker hands clients the *advertised* address to reconnect on after bootstrap. One advertised address can serve in-network DNS (`shared-kafka:9092`) **or** host loopback (`127.0.0.1:49092`), never both — so the template declares **two** listeners (`internal`/`external`). The naive single-listener config works from inside the network and silently fails the host `rpk` (and vice-versa). +- **Host-side tools can NOT reach an in-network-only engine.** With the default no-published-port policy a shared engine is only on `devstack_shared`; any tool running **from the host** (the AWS SDK, `aws`/`awslocal`, `nats`, `rpk`, `rabbitmqadmin`, like `pgx` for Postgres) needs the `127.0.0.1` up-time overlay first. This is the same constraint that forces `pg-provision` to publish 45432 — the host pgx connection cannot resolve `shared-postgres`. +- **LocalStack health is per-service, not a flat 200.** `GET /_localstack/health` returns each service's state (`available`/`running`); a service in `SERVICES` is **lazy** and only flips to `running` on first use. Gate the healthcheck on the **edge** being up (`grep running`), not on every service, or the container never reports healthy until traffic arrives. +- **LocalStack persistence is best-effort in community.** `PERSISTENCE=1` + the `/var/lib/localstack` volume survives restarts on a best-effort basis; deep state durability is a Pro feature. Document it: treat LocalStack data as **recreatable**, and let [spec 29](29-resource-commands.md) re-create resources idempotently from the `provisioned` ledger rather than relying on the volume. +- **Bucket / stream / table / topic namespaces are FLAT and GLOBAL inside one instance** — identical to the MinIO bucket caveat ([spec 15](15-db-snapshot-restore.md)). Two projects both asking for `uploads` would collide. **Prefix every resource by project** (`-uploads`) and key the `provisioned` ledger by `(ctx, project, kind, name)` so tenant isolation holds and `db gc` reaps the right rows. +- **NATS image has no `curl`.** Healthcheck must use the bundled `wget` (or the `nats` binary if present) against `/healthz` on the `-m 8222` monitor port — a `CMD curl` test fails as "executable not found" and the engine never goes healthy. +- **RabbitMQ first boot is slow** (Erlang VM + Mnesia schema) — set `start_period: 30s` and use `rabbitmq-diagnostics -q ping` (not a TCP probe), or the saga's health gate flaps and aborts a healthy bring-up. +- **Secrets never inline.** `RABBITMQ_DEFAULT_PASS`, LocalStack Pro `authToken`, and any generated broker credential are `secretAttrs` (`resolver.go:16`) — emit valueless `environment: [NAME]` and pass the value via `exec.Cmd.Env` at compose-up (or push to a provider via the [spec 04](04-secrets.md) Pusher). The existing CI test asserting no secret value lands in a generated file must pass for these templates. +- **External binaries are `info`-level only.** `aws`/`awslocal`, `nats`, `rpk`, `rabbitmqadmin` are host tools behind `internal/` seams ([spec 29](29-resource-commands.md)); their absence degrades only the matching resource verbs, never `up` — the `mkcert`/`cloudflared`/`pg_dump` posture ([DECISIONS D11/D12](../DECISIONS.md), [spec 15](15-db-snapshot-restore.md)). The templates add **zero** Go deps; `CGO_ENABLED=0` static binary is untouched. +- **Never recreate a stateful shared engine.** A param change that would force-recreate `shared-kafka`/`shared-rabbitmq` (e.g. a volume-path or listener change) is gated by the never-recreate guard ([spec 03](03-workspaces-and-shared-services.md)) — it would drop every tenant's connections. Bumping the **major** spins a *new* `(engine, major)` instance instead of mutating the running one. +- **Determinism vs runtime.** Only **generated artifacts** are determinism-gated; the host-port overlay, ledger rows, and [spec 29](29-resource-commands.md) resource creation are runtime mutations and are explicitly **not** byte-compared — same split as `pg-provision`. + +## Acceptance criteria +- [ ] `templates/localstack`, `templates/nats`, `templates/kafka`, `templates/rabbitmq` each pass `template lint` (non-empty `provides:`+`defaultPort:`, valid `service:`/`healthcheck:`) and `template test` golden render. +- [ ] A workspace with `uses: workspace.shared.localstack` brings up exactly one `shared-localstack` on `devstack_shared`, reachable in-network at `shared-localstack:4566`, with **no** host port in deterministic `generate` output. +- [ ] `${ref:workspace.shared.localstack.endpoint}` resolves to `http://shared-localstack:4566` and `.region` to the param; `${ref:...localstack.monitorPort}`-style secondary-port attrs resolve via the new export-attr→port lookup; a `${ref:...localstack.secret}` (or any `secretAttrs` attr) is **rejected** at generate time. +- [ ] Two projects pinning `kafka` major `24` vs `25` produce two ledger-keyed instances `shared-kafka-24`/`-25`, ref-counted independently ([spec 03](03-workspaces-and-shared-services.md) parity test). +- [ ] Each engine reports **healthy** under its declared healthcheck within `start_period`; the up saga's health gate passes (Redpanda via `rpk cluster health`, NATS via `/healthz`, RabbitMQ via `rabbitmq-diagnostics ping`, LocalStack via edge `running`). +- [ ] A host-reachability request publishes a `127.0.0.1`-only overlay port via `FreeHostPort(ctx, sharedAlias(inst), "-host", base)` allocated inside the flock; `generate` output is byte-identical with and without the overlay. +- [ ] Kafka template advertises two listeners; an in-network client reaches `shared-kafka:9092` and host `rpk` reaches `127.0.0.1:49092` in the same run. +- [ ] `RABBITMQ_DEFAULT_PASS` appears as a valueless per-service env key in generated compose; the secret value appears in **no** generated file (CI assertion). +- [ ] `ministack` is authored as an AWS-emulation engine template (`provides: aws`, the ministack.org image) once its image tag/edge port/`SERVICES` knob/health endpoint are confirmed against ministack.org; it is interchangeable with LocalStack by template name (both `engine=aws`). + +## Dependencies / consumers +Consumes `internal/scaffold` (the `provides:`-required builder), `internal/template` (the `[[ ]]` engine + `go:embed`/store chain), `internal/generate` (`sharedAttr`/`sharedAlias` + the new non-secret export cases and the secondary-port lookup), `internal/workspace` (`(engine, major)` ledger keying, ref-counting/reconcile, `FreeHostPort(ctx, owner, purpose, base)`), `internal/state`+`internal/lock` (the `provisioned` free-text-`kind` ledger + flock), `internal/secrets` (valueless env injection for broker creds, [spec 04](04-secrets.md)). **Consumed by** [spec 29](29-resource-commands.md) (the `aws`/`nats`/`kafka`/`amqp` resource commands that provision buckets/queues/streams/topics inside these engines — the imperative sibling, with [spec 15](15-db-snapshot-restore.md) as the data-command precedent) and `internal/doctor` (the new `info`-level `bin.aws`/`bin.nats`/`bin.rpk`/`bin.rabbitmqadmin` probes, introduced with spec 29's verbs). **Thin (~1w):** LocalStack + NATS templates with health + host overlay + the new `endpoint`/`region` export attrs. **Full (~2.5w):** adds Kafka (Redpanda) + RabbitMQ, the secondary-port resolver lookup, the `ministack` AWS-emulation engine template (once ministack.org config is confirmed), and golden tests. + +## Open questions +**Q-MINISTACK — RESOLVED (owner):** `ministack` is the [ministack.org](https://ministack.org/) container image — a real AWS-local-emulation runtime, a lighter alternative to LocalStack. It is authored as a normal AWS-emulation **engine template** (`provides: aws`), interchangeable with LocalStack by template name (both key the ledger `engine=aws`). **Remaining sub-task before authoring:** confirm the exact image repo/tag, edge/gateway port, service-selection env knob, and health endpoint against the ministack.org docs (the recon found zero references; spec uses placeholders). +**Q-SECONDARY-PORTS** — how do `monitorPort`/`adminPort`/`mgmtPort` resolve, given the resolver tracks only one in-network port per shared service (`sharedPort map[string]int`)? **Recommendation:** add a small static per-template export-attr→port map in `internal/generate` (driven off the template's declared secondary ports), not a hardcode in `sharedAttr`. **Decision:** owner to confirm whether secondary-port exports ship in v2 or are deferred (host-published mgmt/monitor UIs work without a `${ref}` export). +**Q-KAFKA-RUNTIME** — Redpanda vs Apache Kafka KRaft as the `kafka` engine. **Recommendation:** Redpanda (single static binary, no JVM/quorum, healthy in seconds; `provides: kafka` keeps consumers implementation-agnostic). **Decision:** owner to confirm Redpanda as the default `kafka` image, with an `image:` param escape hatch to `apache/kafka` for parity testing. +**Q-LOCALSTACK-EDITION** — community default vs Pro opt-in. **Recommendation:** community (`localstack/localstack`, Apache-2.0) default; Pro only via owner-set `image`+`authToken` (token through `env.import`). **Decision:** owner to confirm community-only ships v2; Pro support is param-gated, not bundled. +**Q-SPEC-NUMBERING** — specs 26/27 are unallocated and this pair claims 28/29. **Decision:** owner to reserve 26/27 (or renumber this pair to 26/27) and add the FEATURES.md #18/#19 rows before merge. +**Q-DAEMON** ([../OPEN-QUESTIONS.md](../OPEN-QUESTIONS.md)) — no daemon → these engines stay warm with no autostop; `shared gc` reclaims zero-ref instances on demand (inherited, not re-argued here). \ No newline at end of file diff --git a/docs/specs/29-resource-commands.md b/docs/specs/29-resource-commands.md new file mode 100644 index 0000000..253f3cd --- /dev/null +++ b/docs/specs/29-resource-commands.md @@ -0,0 +1,167 @@ +# Spec 29 — Imperative resource commands (db / s3 / queue / stream) + +**Module:** `internal/resource` (+ `internal/provision`, `internal/cli`) · **Milestone:** M2+ (v0.x; queue/stream gated on [spec 28](28-cloud-engine-templates.md) engines) · **Effort:** ~5w (DB+S3 ~2w on the existing M2 substrate; messaging ~3w once the engines land) + +## Purpose +Give each project a **tenant-scoped, imperative resource surface** on the shared engines: create the extra databases, SQL roles/grants, object-storage buckets (with lifecycle/versioning/policy/CORS), queues, pub/sub topics, and durable streams a real app needs — without hand-driving `psql`, `mc`, or `aws --endpoint-url` and without a duplicate stack per repo. Today the only data a project gets is one auto-provisioned role+db ([spec 03](03-workspaces-and-shared-services.md)); a Laravel app that wants three databases, a `reports` read-only role, an `uploads` bucket with a 30-day expiry rule, and an SQS queue has to leave the tool and run engine CLIs by hand. This spec is the concrete verb-by-verb command layer that sits on the **architecture** in [spec 27](27-resource-layer.md) (the `resources:` provisioner contract + host-reachability overlay) and the **engines** in [spec 28](28-cloud-engine-templates.md) (LocalStack/NATS/Kafka templates). Every imperative verb is the *same* provisioner the declarative `resources:` block runs at `up` — built once, callable two ways. + +> **Distinct from `db gc` ([spec 13](13-doctor-diagnostics-and-teardown.md)) and `db snapshot` ([spec 15](15-db-snapshot-restore.md)):** `db gc` reaps orphaned ownership rows a removed project left behind; `db snapshot/restore` is the per-tenant *data* workflow (`internal/db`, still unbuilt). Spec 29 is the *structure* layer — it creates and inspects the resources themselves. All three share the `provisioned` ownership ledger but never overlap in verbs. None of these command groups exist in the tree yet — they are net-new on top of the M2 ledger/provision/lock substrate. + +## Decisions +- **One new module, `internal/resource`, owns the verb→provisioner dispatch.** It does **not** re-implement provisioning: Postgres reuses `provision.Postgres` (extended with new `EnsureRole`/`Grant` methods on the existing `Conn` interface); MinIO/LocalStack/NATS/Kafka each get a `Provisioner` behind the same small interface (all net-new — `internal/provision` is Postgres-only today). The CLI commands are **standalone cobra commands that mirror the `provisionPhase` body** (`internal/orchestrate/provision.go:132-207`), **not** new saga phases — imperative resource ops are invoked directly; the saga phase handles the declarative `resources:` block. +- **Tenant-scoped naming is mandatory and enforced, not advisory.** A bucket/queue/stream/db name the user types is prefixed with the project (`_` for SQL idents, `-` for DNS/bucket/queue names) so project A can never name-collide with or reach project B's resources — the same isolation guarantee that makes the shared stack safe ([spec 03](03-workspaces-and-shared-services.md) §isolation). `--no-prefix` is an explicit escape hatch (Q-RES-NAMING). +- **Every mutation goes through the flock and records a `provisioned` row; reads are lock-free.** The canonical shape is `lock.WithLock(ctx, LockPath, func() error { …driver call…; DB.RecordProvisioned(project, kind, name); DB.LogEvent(kind, name, reason) })` — exactly `provisionPhase` (`provision.go:182,186`). The `provisioned.kind` column is **free-text** (`state/ledger.go:290`; existing values `database`/`role`/`redis_index`), so new kinds (`bucket`, `queue`, `topic`, `stream`, `lifecycle`) need **no migration**. +- **Single-resource teardown needs one net-new ledger op.** Reclaiming a *single* row (`db drop`, `s3 rb`, `queue rm`, …) cannot reuse `RemoveProvisionedForProject` (`state/ledger.go:329`), which drops a project's *entire* ownership set during teardown. Add a sibling `RemoveProvisioned(project, kind, name)` (same table, no migration) used by the destructive verbs; `db gc` keeps using `OrphanedProvisioned`/`RemoveProvisionedForProject` unchanged. +- **Engine tooling is external binaries behind one interface**, mirroring the docker/git/`mc` discipline (the pure-Go static-binary rule means these tools cannot be in-process). `mc` (MinIO/S3), `aws` (LocalStack), `nats` (NATS), `rpk`/`kafka-topics` (Kafka) shell out via `internal/resource`'s `Tool` seam with an injectable `Runner` and a `CmdError` carrying cmd+code+stderr — the same `internal/docker` Compose-driver pattern. `pgx/v5` stays the only in-process runtime SDK. +- **Native vs LocalStack is an endpoint swap, not a branch.** `s3 mb` against MinIO and against LocalStack S3 run the *same* `mc`/`aws` call against a different `--endpoint-url`; `queue create --engine sqs` always means LocalStack. The provisioner picks the endpoint from the active shared engine's host-port overlay ([spec 27](27-resource-layer.md)), never from a per-command flag. +- **Host reachability reuses the up-time overlay, not the deterministic generate output.** Host-side tools (`mc`, `aws`, `pgx`) run **from the host**, so each engine that needs host ops publishes a `127.0.0.1`-only port via a `compose.provision.yaml` overlay with a ledger-allocated port (`Manager.FreeHostPort`, a new `purpose`+base per engine), exactly as Postgres does (`provisionPurpose="pg-provision"`, base `45432`, `provision.go:29-31`). The golden `generate` output stays byte-identical. +- **Two credential patterns, per-resource policy.** (a) **predictable dev cred** — like Postgres' `password = project name` (`provision.go`): nothing secret stored, attrs reach containers via the `env.import` resolver. (b) **generated secret** via the secrets Pusher ([spec 04](04-secrets.md), feature #16: aws-sm/aws-ssm/infisical + SOPS) for resources that mint a real key (a scoped MinIO access key, an SQS-consumer IAM key) → stored in the provider, injected as a **valueless** per-service env key. Never plaintext on disk. +- **Imperative and declarative are the same provisioner.** A project declares what it needs in `devstack.yaml`'s `resources:` block ([spec 27](27-resource-layer.md)); `up` provisions it via a new resources saga phase. `devstack ` calls the identical provisioner ad-hoc. `resources:` is the reproducible source of truth; the imperative verbs are for exploration and one-offs. +- **`db gc` is the single reaper for every kind.** New kinds (`bucket`/`queue`/`stream`/`role`) drop into the existing `OrphanedProvisioned`/`RemoveProvisionedForProject` machinery (`state/ledger.go:313,329`). `db gc` is **owned by [spec 13](13-doctor-diagnostics-and-teardown.md)** (the substrate exists; the verb is still v1-scoped/unbuilt); spec 29 only **extends** it with a per-kind delete call so the reaper can drop the orphan's actual engine object — no new reaper verb. + +## Domain × engine provisioner map +The core table — each cell is the host-side tool call the `Provisioner` shells (or the in-process `pgx` SQL), against the ledger-allocated `127.0.0.1` overlay port. **Phase legend:** `now` = buildable on the current M2 substrate (no spec 28); the provisioner code is still net-new. `later` = needs the [spec 28](28-cloud-engine-templates.md) engines. + +| Domain | Engine | `provides` / instance key | create call | ledger `kind` | phase | +|---|---|---|---|---|---| +| **database** | postgres | `postgres`/`(postgres,major)` | `pgx` guarded `CREATE DATABASE … OWNER` | `database` | now | +| **db role/grant** | postgres | `postgres` | `pgx` guarded `CREATE ROLE` + `GRANT` | `role` | now | +| **database** | mysql *(later)* | `mysql`/`(mysql,major)` | `CREATE DATABASE` + `CREATE USER`+`GRANT` (mysql driver behind `Conn`) | `database` | later | +| **object store** | minio | `minio` | `mc mb /` | `bucket` | now | +| **object store** | localstack (S3) | `localstack`/`s3` | `aws s3 mb` / `aws s3api create-bucket` | `bucket` | later | +| **s3 lifecycle** | minio | `minio` | `mc ilm rule add --expiry-days N` | `lifecycle` | now | +| **s3 lifecycle** | localstack (S3) | `localstack` | `aws s3api put-bucket-lifecycle-configuration` | `lifecycle` | later | +| **queue** | sqs (localstack) | `localstack`/`sqs` | `aws sqs create-queue` (+ redrive→DLQ) | `queue` | later | +| **queue** | redis | `redis` | logical: register a list/stream key namespace + reserve index (`AllocateRedisIndex`) | `queue` | now* | +| **queue** | nats | `nats` | `nats stream add` (work-queue retention) | `queue` | later | +| **topic** (pub/sub) | sns (localstack) | `localstack`/`sns` | `aws sns create-topic` (+ `subscribe` to a queue) | `topic` | later | +| **topic** | nats | `nats` | subject convention `..>` (+ optional stream) | `topic` | later | +| **topic** | redis | `redis` | Pub/Sub channel namespace `:` (ephemeral; no provision row needed) | `topic` | now* | +| **stream** | nats | `nats`/JetStream | `nats stream add --subjects … --retention limits --max-age …` | `stream` | later | +| **stream** | kafka | `kafka` | `rpk topic create -p N -r M` / `kafka-topics --create` | `stream` | later | + +\\* *redis queue/topic are thin in v0.x — Redis only allocates a logical index (`state/ledger.go:341`, the one existing non-PG allocator); the "queue" is a key-prefix convention the app honours, not a server-side object. Full SQS-grade semantics need LocalStack ([spec 28](28-cloud-engine-templates.md)).* + +## CLI surface +### Databases (postgres now; mysql later) +``` +devstack db create [--owner ] [--project P] +devstack db user create [--db ] [--role read|write|admin] [--password |--generate] +devstack db grant --on --as read|write|admin +devstack db list [--kind db|role] [--project P] [--json] +devstack db drop [--kind db|role] [--yes] +devstack db gc [--yes] [--json] # spec 13 verb (substrate exists); extended here to ALL kinds +``` +- `db create orders` on project `api` → `pgx` guarded `CREATE DATABASE api_orders OWNER api` → `RecordProvisioned("api","database","api_orders")`. Idempotent (existence-guarded; `CREATE DATABASE` is **not** idempotent on its own — [DECISIONS D8](../DECISIONS.md)). (The user-facing `--kind db` is a display alias; the stored ledger kind is `database`, matching `provision.go:186`.) +- `db user create reports --db api_orders --role read` → guarded `CREATE ROLE api_reports LOGIN` + `GRANT CONNECT`+`USAGE`+`SELECT` (read), `…+INSERT/UPDATE/DELETE` (write), `…+ALL`/owner (admin). Extends `provision.Postgres` with new `EnsureRole(ctx, conn, role, pw)` + `Grant(ctx, conn, role, db, level)` methods, reusing the existing `Conn` interface (`{Exec, Exists}`, `provision.go:18`) so it stays unit-testable without a live server. +- `--generate` mints a password via the secrets Pusher and stores it; default is the predictable `password = ` dev cred (loopback-only, container-isolation-is-a-non-goal threat model, per `provision.go`). + +### Object storage (minio now; localstack S3 later) +``` +devstack s3 mb [--versioning] [--no-prefix] +devstack s3 rb [--force] [--yes] +devstack s3 ls [bucket] [--json] +devstack s3 lifecycle set --expire-days N | --transition days=N,tier=T [--prefix P] +devstack s3 lifecycle get|rm +devstack s3 versioning --enable|--suspend +devstack s3 policy set --public-read | --file policy.json +devstack s3 policy get +devstack s3 cors set --file cors.json | s3 cors get +``` +- `s3 mb uploads` on project `web` → `mc mb local/web-uploads` (project-prefixed for the globally-unique bucket namespace) → `RecordProvisioned("web","bucket","web-uploads")`. `--no-prefix` for an external-contract name (Q-RES-NAMING). +- `s3 lifecycle set web-uploads --expire-days 30` → `mc ilm rule add --expiry-days 30 local/web-uploads` (the user's explicit object-lifecycle ask). `--transition` is engine-conditional (Q-RES-LIFECYCLE-PORTABILITY). +- `s3 versioning … --enable`, `s3 policy`, `s3 cors` map 1:1 to `mc version enable`, `mc anonymous set`/`mc policy`, and `mc cors set`. Against LocalStack the identical intents run as `aws s3api put-bucket-versioning|put-bucket-policy|put-bucket-cors` — endpoint swap only. + +### Messaging — queues & topics (gated on spec 28 engines) +``` +devstack queue create [--engine sqs|redis|nats] [--fifo] [--dlq ] [--max-receive N] +devstack queue list [--engine ...] [--json] +devstack queue rm [--yes] +devstack topic create [--engine sns|nats|redis] [--subscribe ] +``` +- `queue create jobs --engine sqs --dlq jobs-dead --max-receive 5` → `aws sqs create-queue --queue-name web-jobs` + a redrive policy pointing at `web-jobs-dead` (created first) → `RecordProvisioned("web","queue","web-jobs")`. `--fifo` appends `.fifo` and sets `FifoQueue=true` (AWS requires the suffix). +- `--engine redis` → reserves a key namespace `:queue:` (app uses `LPUSH`/`BRPOP` or a Stream); thin, no server object. +- `topic create events --engine sns --subscribe web-jobs` → `aws sns create-topic` + `aws sns subscribe` wiring the SQS queue (the classic SNS→SQS fan-out). +- Default `--engine` is inferred from the active shared engines, never auto-started (Q-RES-ENGINE-DEFAULT). + +### Messaging — streams (gated on spec 28 engines) +``` +devstack stream create [--engine nats|kafka] [--partitions N] [--retention DURATION] [--replicas N] +devstack stream list [--json] +devstack stream rm [--yes] +``` +- `stream create orders --engine nats --retention 168h` → `nats stream add web_orders --subjects 'web.orders.>' --retention limits --max-age 168h` → `RecordProvisioned("web","stream","web_orders")`. +- `stream create orders --engine kafka --partitions 6 --replicas 1` → `rpk topic create web.orders -p 6 -r 1` (Redpanda) or `kafka-topics --create` (Apache). `--partitions`/`--replicas` are Kafka-only; `--retention` maps to NATS `--max-age` or Kafka `retention.ms` — validated per-engine at parse time. + +### The thin `aws` ergonomic surface +``` +devstack aws -- # e.g. devstack aws -- s3 ls / devstack aws -- sqs list-queues +``` +A pure **argv shim** over the user's own `aws` binary: devstack resolves the LocalStack host-port from the ledger overlay and prepends `--endpoint-url=http://127.0.0.1:`, `--region`, and dev creds (`AWS_ACCESS_KEY_ID=test` etc. via `exec.Cmd.Env`, never argv). It does **not** reimplement any AWS call (Q-AWS-WRAP). Recommendation in the docs is unchanged: real `aws --endpoint-url` works too; the shim just removes four flags and the port lookup. + +## Declarative complement — the `resources:` block +The same provisioners run at `up` from an additive `resources:` block in `devstack.yaml` ([spec 27](27-resource-layer.md); idiomatic per [ARCHITECTURE §7.4](../ARCHITECTURE.md) additive blocks, validated by the existing custom cross-ref resolver): +```yaml +# devstack.yaml +resources: + databases: + - name: orders # → _orders, owner = project role + roles: + - name: reports + grant: { on: orders, as: read } + buckets: + - name: uploads + versioning: true + lifecycle: { expire_days: 30 } + queues: + - name: jobs + engine: sqs + dlq: { name: jobs-dead, max_receive: 5 } + streams: + - name: events + engine: nats + retention: 168h +``` +A new **resources saga phase** (after `provisionPhase`, same `lock.WithLock` shape) walks this block and calls the identical `Provisioner`s, recording the same ledger rows — so `up` is reproducible and `devstack s3 mb …` is the exploratory equivalent. A resource removed from `resources:` is left in place (never auto-dropped; `db gc` / explicit `rm` reclaims), honouring the never-destroy-silently posture. + +## Behavior +1. **Resolve workspace + project + engine instance.** Walk up for `workspace.yaml`, load the model, pick the project (`--project` or cwd). Resolve which shared instance backs the domain (e.g. `(postgres,major)` for `db`, the `minio`/`localstack` instance for `s3`). Error with a `uses:`/`resources:` remediation if the engine isn't in the workspace. +2. **Ensure host reachability.** Confirm the engine is healthy (reconcile from live containers, [spec 03](03-workspaces-and-shared-services.md)); resolve/allocate its `127.0.0.1` overlay port via `Manager.FreeHostPort(ctx, alias, purpose, base)` (idempotent — returns the port the up saga already allocated) and ensure the `compose.provision.yaml` overlay is applied. No daemon; this is on-demand per invocation. +3. **Compute the tenant-scoped physical name** (`_` SQL / `-` DNS-safe), unless `--no-prefix`. +4. **Preflight the engine tool** (`Tool.Preflight()` — binary present + version-compatible). Absence is an `info`-level `doctor` probe that degrades only this verb, never `up` (the `mkcert`/`cloudflared` external-binary posture, [DECISIONS D11/D12](../DECISIONS.md)). +5. **Take the flock and mutate** (`lock.WithLock`): run the provisioner's create call (`pgx` SQL / `mc` / `aws` / `nats` / `rpk`) — **existence-guarded / idempotent** — then `RecordProvisioned(project, kind, physicalName)` and `LogEvent(kind, name, reason)`. Reads (`list`) skip the lock entirely (snapshot). +6. **Mint/store credentials per policy.** Predictable dev cred → nothing stored, attrs flow via the `env.import` resolver (`generate/resolver.go` `sharedAttr`). Generated secret → push to the secrets provider, register a **valueless** per-service env key for compose-up injection. +7. **Emit the connection block.** Plain TTY: a human DSN/endpoint/queue-URL; `--json`: the machine schema (`{kind,name,physical,endpoint,dsn?,owner?}`); `--quiet`: the one connection string. Secret values are redacted unless `--show-secrets` (diagnostics only). For `db create orders`: `postgres://api:api@shared-postgres:5432/api_orders`. +8. **Destructive verbs (`drop`/`rb`/`rm`) confirm.** Require a TTY confirm naming project+kind+name, or `--yes`; run the drop inside the flock, then call the **new** `RemoveProvisioned(project, kind, name)` ledger op (sibling to `RemoveProvisionedForProject`, `ledger.go:329`), then write a `*.drop` `event_log` row. Never recreate/bounce the shared engine — operate only on the tenant object (the never-recreate-a-stateful-shared-service guard, [spec 03](03-workspaces-and-shared-services.md) §50). + +## Verified constraints / gotchas +- **`CREATE DATABASE` / `CREATE ROLE` are not idempotent — guard them.** The naive `CREATE DATABASE x` fails on the second run and crashes a concurrent second terminal with `role already exists`. Reuse the existence-guarded `pgx/v5` SQL (`SELECT 1 FROM pg_database…` then create) that `provision.Postgres` already uses (`provision.go:48,64`; [DECISIONS D8](../DECISIONS.md)); `db user create`/`grant` extend the same guarded pattern, never `initdb.d`. +- **Host tools run from the host, so the engine needs a published port — but the default is none.** The naive choice (publish `5432`/`9000` in the generate output) breaks determinism and the "no host ports by default" posture. Do what Postgres provisioning does: a `127.0.0.1`-only **up-time overlay** (`compose.provision.yaml`, ledger-allocated port via `FreeHostPort`, new `purpose`+base per engine, e.g. `minio-provision`/`localstack-provision`) so `generate`'s golden output is untouched ([spec 27](27-resource-layer.md), `orchestrate/provision.go:102-105`). +- **Secret attributes must not be inline `${ref}`s.** `generate/resolver.go` **rejects** `password`/`secretkey`/`secret`/`token` as inline refs (`secretAttrs` set, lines 16-20; rejection at 66-67) — a generated MinIO/SQS key cannot be string-templated into a compose file (it would land in plaintext on disk, violating the [spec 04](04-secrets.md) coupling). Mint it via the Pusher, store in the provider, and emit it as a **valueless** per-service `environment: [NAME]` key filled from `exec.Cmd.Env` at compose-up — a CI test asserts no secret value lands in any generated file. +- **Bucket/queue/stream names are a flat global namespace per engine — prefix or collide.** Two projects both wanting `uploads` would clobber each other on the shared MinIO. Tenant-prefix every name (`-uploads`) so isolation holds, the same reason the shared Postgres uses `_db`. `--no-prefix` is opt-in and the user owns the collision. +- **SQS FIFO queues require the `.fifo` suffix and `FifoQueue=true` together** — setting one without the other is an AWS API error LocalStack faithfully reproduces. The provisioner sets both atomically when `--fifo`. +- **MinIO ILM tiers ≠ S3 storage classes.** `mc ilm` transition tiers are admin-configured remote targets; S3 `--transition` takes `STANDARD_IA`/`GLACIER`. `--expire-days` is portable across both; `--transition` is engine-conditional and refused with a remediation on MinIO unless a tier is pre-configured (Q-RES-LIFECYCLE-PORTABILITY). +- **NATS work-queue vs Kafka partitions are different retention models.** A NATS JetStream "queue" is a stream with `WorkQueuePolicy` retention; a Kafka "stream" is a partitioned topic. Don't pretend `--partitions` is universal — validate it as Kafka-only at parse time, and map `--retention` to NATS `--max-age` vs Kafka `retention.ms` per engine. +- **Every engine tool is an external binary, not pure-Go** — `mc`/`aws`/`nats`/`rpk` break the "single static binary needs nothing installed" promise for *these verbs only* (the `CGO_ENABLED=0` rule forbids in-process SDKs that aren't pure-Go). They sit behind the `internal/resource` `Tool` seam with an injectable `Runner` (so race/unit tests run without the binary, like `internal/docker`'s `MockClient`) and are `info`-level `doctor` probes — consistent with `bin.pg_dump`/`bin.mc` ([spec 15](15-db-snapshot-restore.md)). Only `pgx` stays in-process. +- **Filter container enumeration on the tool's own label, `All=true`, exclude `oneoff=true`** when locating the engine for the overlay — the `com.docker.compose.project` label is the normalized name and silently no-ops if mis-computed ([spec 03](03-workspaces-and-shared-services.md) §45). +- **Ledger ops are not determinism-gated, but they ARE flock-gated.** Resource creation is runtime content-mutation (like provision-on-demand) — it's *not* subject to the byte-identical-output rule, but every `provisioned`/`event_log` write and every engine mutation MUST be inside `lock.WithLock` (SQLite + WAL + `busy_timeout` alone is not safe under concurrent writers on WSL2/9p). Reads stay lock-free. + +## Acceptance criteria +- [ ] `db create orders` on project `api` produces `api_orders` owned by the `api` role, records a `provisioned(kind=database)` row, is idempotent on re-run, and prints the DSN; project `web` running the same command gets `web_orders` and cannot see `api_orders`. +- [ ] `db user create reports --db api_orders --role read` creates `api_reports` with SELECT-only grants; `db grant api_reports --on api_orders --as write` upgrades it; both run guarded SQL via the new `EnsureRole`/`Grant` methods inside the flock. +- [ ] `s3 mb uploads --versioning` creates `web-uploads` with versioning enabled via `mc`, records `provisioned(kind=bucket)`, and `s3 lifecycle set web-uploads --expire-days 30` adds an ILM expiry rule; `s3 ls --json` lists exactly `web`'s buckets. +- [ ] The same `s3 mb`/`s3 lifecycle` intents run against a LocalStack S3 instance with only an endpoint swap (no code-path branch), asserted by a test that runs both backends through one `Provisioner`. +- [ ] `queue create jobs --engine sqs --dlq jobs-dead --max-receive 5` creates `web-jobs` + `web-jobs-dead` with a redrive policy against LocalStack; `--engine redis` reserves a key namespace and a logical index instead. +- [ ] `stream create events --engine nats --retention 168h` creates a JetStream stream with `max-age=168h`; `--engine kafka --partitions 6` creates a 6-partition topic; `--partitions` is rejected for `--engine nats`. +- [ ] A `resources:` block in `devstack.yaml` provisions the identical databases/buckets/queues/streams at `up` via the resources saga phase, recording the same ledger rows as the imperative verbs (one provisioner, two entry points). +- [ ] A generated MinIO/SQS access key is pushed to the secrets provider and injected as a **valueless** env key; CI asserts no secret value appears in any generated compose/env file. +- [ ] `db drop`/`s3 rb`/`queue rm`/`stream rm` require a TTY confirm or `--yes`, operate only on the tenant object (never bounce the shared engine), log an `event_log` row, and reclaim the row via the new `RemoveProvisioned(project,kind,name)`. +- [ ] `db gc` (spec 13 verb) reaps orphaned rows of **every** kind (database, role, bucket, queue, stream) for removed projects with explicit confirmation — no new reaper code per kind beyond the per-kind engine-delete call. +- [ ] Two concurrent `s3 mb` invocations in two terminals → no `database is locked`, no duplicate port allocation, no double-create crash (flock + idempotent engine calls). +- [ ] All engine tools are reachable behind `internal/resource`'s mockable `Tool`/`Runner`; the full verb matrix has unit/race coverage with no real `mc`/`aws`/`nats`/`rpk` binary present. + +## Dependencies / consumers +Consumes `internal/provision` (the `pgx/v5` existence-guarded role/db SQL — extended with net-new `EnsureRole`/`Grant` methods on the existing `Conn` interface, reused verbatim by the resources phase), `internal/state` + `internal/lock` (the `provisioned` ledger — free-text `kind`, `FreeHostPort`, `RecordProvisioned`/`OrphanedProvisioned`/`RemoveProvisionedForProject` + a new `RemoveProvisioned(project,kind,name)` — and the flock spine), `internal/workspace` (`Manager.FreeHostPort` for the overlay port), `internal/docker` (read-only engine enumeration + the Compose driver that applies the host-port overlay), `internal/orchestrate` (the resources saga phase mirroring `provisionPhase`), `internal/config` (the additive `resources:` block + cross-ref validation, [spec 01](01-config-schema.md)), `internal/secrets` (the Pusher for generated keys, [spec 04](04-secrets.md)), [spec 27](27-resource-layer.md) (the provisioner contract + overlay), [spec 28](28-cloud-engine-templates.md) (the LocalStack/NATS/Kafka engine templates the messaging verbs target). Consumed by `internal/cli` (`db`/`s3`/`queue`/`topic`/`stream`/`aws` command groups — all net-new), `internal/doctor` (the `bin.mc`/`bin.aws`/`bin.nats`/`bin.rpk` info probes), and `db gc` ([spec 13](13-doctor-diagnostics-and-teardown.md), the cross-kind reaper). **Thinner v0.x (~2w):** `db create|user|grant|list|drop` (postgres) + `s3 mb|rb|ls|lifecycle|versioning|policy|cors` (minio) — net-new provisioners on the existing M2 ledger/lock/provision substrate. **Full (~5w):** adds the LocalStack/NATS/Kafka `queue`/`topic`/`stream` verbs, the `aws` shim, generated-secret keys, and the declarative `resources:` saga phase — gated on [spec 28](28-cloud-engine-templates.md). + +## Open questions +[Q-DAEMON](../OPEN-QUESTIONS.md) (no daemon → all resource ops are explicit/on-demand; no background reconcile of drift between `resources:` and live state), [Q-RUNTIME](../OPEN-QUESTIONS.md) (Docker-only engines). **New:** **Q-RES-NAMING** — transparent vs explicit tenant prefixing (recommend transparent + `--no-prefix`, ledger holds logical+physical). **Decision:** transparent-by-default. **New:** **Q-RES-ENGINE-DEFAULT** — default backend when `--engine` is omitted (recommend infer-from-active-engines, never auto-start). **Decision:** infer, error if unsatisfiable. **New:** **Q-AWS-WRAP** — ship the `aws` shim vs document raw `aws --endpoint-url` (recommend a pure argv shim, info-level probe). **Decision:** thin shim, never reimplement AWS. **New:** **Q-RES-LIFECYCLE-PORTABILITY** — MinIO ILM vs S3 lifecycle transition semantics (recommend portable `--expire-days`, engine-conditional `--transition`). **Decision:** expiry portable now, transitions engine-gated. \ No newline at end of file