inventory --emit-draft: a reviewable blueprint of a live instance (#27) #33

Merged
dan-claude-bot merged 2 commits from feat/inventory-emit-draft into main 2026-07-13 20:46:10 +00:00
dan-claude-bot commented 2026-07-13 20:33:52 +00:00 (Migrated from github.com)

Closes #27.

Stacked on #30 (#25, the registry — the draft writes one). Merge #30 first; this PR's diff then collapses to the #27 change alone.

cast inventory can already sweep a whole instance. Now it can emit what it sees as a draft of cast's own inputs:

cast inventory --env prod --instance box-b --emit-draft ./draft --recipient age1…
draft/
  environments.yaml                    # bindings + the projects: registry (#25) + github_apps
  UNCAPTURED.md
  incubator/.infra/manifest.yaml
  incubator/.infra/env/*.env.template
  la-familia/.infra/manifest.yaml      # the client sites, drafted too
  martin-reyes-barber-shop/.infra/manifest.yaml
  secrets/<project>.<env>.env.age

Every emitted text file opens with a header saying it is a proposal, machine-generated from instance X at time T, and that apply does not read it.

The boundary

A draft is a PROPOSAL. It is never desired state, and apply never reads it. The path is sweep → emit draft → human reviews → manifest PR → captureapply — the same shape as terraform import → HCL. It is emitted from the sweep path only (--emit-draft with a repo is refused: that is the reconcile path, and a project with a manifest is one whose manifest is the truth — regenerating it from a live box would let that box's accumulated cruft overwrite the reviewed spec). It refuses a target directory that already holds a repo. Adoption is one-way.

The two things that would make a draft actively dangerous

1. Copied provider-generated values. A DATABASE_URL read off the source points at the source box's Postgres. Emit it, rebuild elsewhere, and the new box comes up working — reading and writing the old box's database. You find out the day the old box is deleted.

So provider-generated names are placeheld and flagged, never copied. Decided by name, never by value, in two families: Coolify's magic vars (^SERVICE_(FQDN|URL|USER|PASSWORD|BASE64|REALBASE64)), and any name whose segments carry both a datastore word (DATABASE, DB, POSTGRES, REDIS, …) and a connection word (URL, URI, DSN, HOST, PASSWORD, …). It errs wide on purpose: over-matching a real secret placeholds it loudly and recoverably; under-matching a generated one copies it silently and rebuilds a box that works against a dead machine's database. Placeheld names land in the manifest's generated_secrets:, so a later capture repeats the placeholding with no flag to remember.

2. Silent losses. UNCAPTURED.md is a first-class output, emitted on every run, listing per resource every live setting cast saw and could not express: destinations (#21), service hostnames (4.1.2 has no flat domains on services), Basic Auth / custom Traefik labels, Include Source Commit in Build, and anything else with no manifest field. A blueprint that omits these without saying so is worse than no blueprint — in a disaster you would trust it and rebuild a different box.

Three judgment calls worth your eye

  • --environment is a tiebreak, not a filter. The first cut filtered the instance by env name and silently dropped La Familia — which lives alone in production, and is exactly the project this verb exists to bootstrap. Now a project with one populated environment is always drafted; only a project with two forces the flag, and cast refuses rather than picking.
  • A project with no application has no repo on the box (the git remote is the only place a box records one), so the registry key is written bare — and #25's own parse-time refusal ("a registry key has no meaning without its org") then stops the file loading until a human supplies it. That refusal is the design: the alternatives are inventing an org, or dropping the project from the list — and a project missing from the registry is one every fleet run skips in silence.
  • An app whose build pack the manifest cannot express is left out of the manifest, named in UNCAPTURED.md — but its env values are still captured. Values are irrecoverable once the box is gone; structure is not.

Docs

README gains Drafting a box that was never declared; docs/semantics.md gains the Drafts contract. Both carry the what a blueprint cannot restore table — the GitHub App private key and the S3 access keys are not in the repo (correctly: it holds no live credentials) and cannot be regenerated from it. A DR runbook has to say so.

The load-bearing test

Asserts the source's real DATABASE_URL, REDIS_URL and SERVICE_PASSWORD_* values appear in no emitted artifact — walking every file in the tree, decrypting the age store (the one place a copied value would hide from a grep), and checking stdout — while an ordinary secret (MAILGUN_KEY) is carried through.

Gates: npm run check, npm run build, npm test (238 passing) all green.

🤖 Generated with Claude Code

Closes #27. > **Stacked on #30** (#25, the registry — the draft writes one). Merge #30 first; this PR's diff then collapses to the #27 change alone. `cast inventory` can already sweep a whole instance. Now it can **emit what it sees** as a draft of cast's own inputs: ```sh cast inventory --env prod --instance box-b --emit-draft ./draft --recipient age1… ``` ``` draft/ environments.yaml # bindings + the projects: registry (#25) + github_apps UNCAPTURED.md incubator/.infra/manifest.yaml incubator/.infra/env/*.env.template la-familia/.infra/manifest.yaml # the client sites, drafted too martin-reyes-barber-shop/.infra/manifest.yaml secrets/<project>.<env>.env.age ``` Every emitted text file opens with a header saying it is a **proposal**, machine-generated from instance X at time T, and that `apply` does not read it. ### The boundary **A draft is a PROPOSAL. It is never desired state, and `apply` never reads it.** The path is **sweep → emit draft → human reviews → manifest PR → `capture` → `apply`** — the same shape as `terraform import` → HCL. It is emitted from the **sweep path only** (`--emit-draft` with a repo is refused: that is the reconcile path, and a project with a manifest is one whose manifest *is* the truth — regenerating it from a live box would let that box's accumulated cruft overwrite the reviewed spec). It refuses a target directory that already holds a repo. Adoption is one-way. ### The two things that would make a draft actively dangerous **1. Copied provider-generated values.** A `DATABASE_URL` read off the source points at the *source box's* Postgres. Emit it, rebuild elsewhere, and the new box comes up **working** — reading and writing the old box's database. You find out the day the old box is deleted. So provider-generated names are **placeheld and flagged**, never copied. Decided **by name, never by value**, in two families: Coolify's magic vars (`^SERVICE_(FQDN|URL|USER|PASSWORD|BASE64|REALBASE64)`), and any name whose segments carry both a datastore word (`DATABASE`, `DB`, `POSTGRES`, `REDIS`, …) and a connection word (`URL`, `URI`, `DSN`, `HOST`, `PASSWORD`, …). It errs **wide** on purpose: over-matching a real secret placeholds it loudly and recoverably; under-matching a generated one copies it silently and rebuilds a box that works against a dead machine's database. Placeheld names land in the manifest's `generated_secrets:`, so a later `capture` repeats the placeholding with no flag to remember. **2. Silent losses.** `UNCAPTURED.md` is a **first-class output, emitted on every run**, listing per resource every live setting cast saw and could not express: destinations (#21), service hostnames (4.1.2 has no flat `domains` on services), Basic Auth / custom Traefik labels, *Include Source Commit in Build*, and anything else with no manifest field. A blueprint that omits these without saying so is worse than no blueprint — in a disaster you would trust it and rebuild a *different box*. ### Three judgment calls worth your eye - **`--environment` is a tiebreak, not a filter.** The first cut filtered the instance by env name and silently dropped La Familia — which lives alone in `production`, and is exactly the project this verb exists to bootstrap. Now a project with one populated environment is always drafted; only a project with *two* forces the flag, and cast refuses rather than picking. - **A project with no application has no repo on the box** (the git remote is the only place a box records one), so the registry key is written bare — and #25's own parse-time refusal (*"a registry key has no meaning without its org"*) then stops the file loading until a human supplies it. That refusal **is** the design: the alternatives are inventing an org, or dropping the project from the list — and a project missing from the registry is one every fleet run skips in silence. - **An app whose build pack the manifest cannot express is left out of the manifest**, named in `UNCAPTURED.md` — but its env values are still captured. Values are irrecoverable once the box is gone; structure is not. ### Docs README gains *Drafting a box that was never declared*; `docs/semantics.md` gains the *Drafts* contract. Both carry the **what a blueprint cannot restore** table — the GitHub App private key and the S3 access keys are not in the repo (correctly: it holds no live credentials) and cannot be regenerated from it. A DR runbook has to say so. ### The load-bearing test Asserts the source's real `DATABASE_URL`, `REDIS_URL` and `SERVICE_PASSWORD_*` values appear in **no** emitted artifact — walking every file in the tree, **decrypting the age store** (the one place a copied value would hide from a grep), and checking stdout — while an ordinary secret (`MAILGUN_KEY`) *is* carried through. Gates: `npm run check`, `npm run build`, `npm test` (238 passing) all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#33
No description provided.