From 40322bbf754d860bf754c44e8dc13753320c4470 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 18:50:06 +0000 Subject: [PATCH] feat: --resource, the third name a hand-built box does not share with you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #20 shipped the refusal without shipping the resolution: capture correctly refuses when a manifest resource does not exist on the source, and then there was no way to say "it's over there, under another name." Found immediately, on the box that motivated it. The manifest says `core`, `landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`, `Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`, `Incubator Umami`. Neither is wrong — one names things for a human reading a UI, the other for a machine reading a diff — and neither gets to overwrite the other. --resource core="Incubator Stack v2" (repeatable) Applied at the boundary: live resources are renamed to the manifest's vocabulary once, immediately after the lookup, so computeDiff / classify / reconcile all match by name exactly as before and none of them needs to know a hand-built box was involved. Read-side only, and `apply` refuses it up front — before a clone, a decrypt or a single call. apply CREATES under the manifest's names, so an alias there could only mean "adopt the existing one instead": a different operation nobody has asked for, whose silent failure mode is a duplicate resource created beside the one you were pointing at. diff needed this as much as capture did. Without it, a --full diff against a box whose resources are named differently reports every manifest resource as "to create" and never mentions the live ones — the D-237 lie by another route, a confident full-create plan against a box that has all of it under other names. That diff is the staleness gate of a live migration. Two smaller things, both about not laundering a naming gap into a pass: - inventory, when NOTHING matched and yet the box has resources, now says so and prints the --resource lines to paste. "The box is empty" is exactly the wrong conclusion, and it was the easy one to draw. - the absent-resource refusal prints the same, per absent resource. An alias whose left side names no manifest resource is an error, not a no-op: a typo would otherwise map nothing, leave the real resource looked-up under its own name, and refuse with no hint that the flag had missed. inventory keeps the box's own name beside ours in the report (`core ← "Incubator Stack v2" on the box`) — a document that renamed the box's resources to our vocabulary and never mentioned theirs would be unusable against the UI it describes. npm run check + build clean; 169 tests passing (was 164). Co-Authored-By: Claude Opus 4.8 --- README.md | 10 ++- src/capture.ts | 6 +- src/cli.ts | 125 +++++++++++++++++++++++++++++++++++-- src/inventory.ts | 30 ++++++++- test/read-side-cli.test.ts | 94 ++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 93ba16b..e84ce29 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,15 @@ yours: | --- | --- | | `--project ` | the project isn't named after the repo (`Incubator`, not `incubator`) | | `--environment ` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) | -| — | a resource isn't named after the manifest's → **refuses**; reconcile with `inventory` first | +| `--resource =` | a resource isn't named after the manifest's (`core` is `Incubator Stack v2` over there). Repeatable | + +All three are **read-side only** — `diff`, `capture`, `inventory`. They are +arguments to a one-off read, never manifest fields: a manifest that recorded a +legacy box's names would carry a dead machine's vocabulary forever. And `apply` +refuses `--resource` outright, because it creates resources under the manifest's +own names — an alias there could only mean *adopt the existing one instead*, +which is a different operation and would otherwise silently create a duplicate +beside the resource you were pointing at. `--env` stays **ours**: it selects the manifest block, the `environments.yaml` binding, the age key, the store path. `--environment` is *theirs*, on the wire, diff --git a/src/capture.ts b/src/capture.ts index f12dc85..5359fca 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -77,7 +77,11 @@ export function renderAbsentResources( "finding is not that the secrets are missing — it is that the manifest and this", "box disagree about what these resources are called.", "", - "Reconcile the names first (`cast inventory` shows both sides), then capture.", + "`cast inventory` shows both sides. Then map them at the call site:", + "", + ...absent.map( + (name) => ` --resource ${name}=""`, + ), ].join("\n"); } diff --git a/src/cli.ts b/src/cli.ts index f7a76c2..ea06016 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,6 +80,12 @@ const USAGE = `usage: cast apply / --env [--path ] [-- \`production\`, not \`prod\`. This changes ONLY the name on the wire — --env still selects the manifest block, the environments.yaml binding, the age key and the store path. + --resource = + the same problem, one level further down: a hand-built box + names resources for a human reading a UI ("Incubator Stack v2"), + a manifest names them for a diff (\`core\`). Repeatable. Read-side + only (\`diff\`, \`capture\`, \`inventory\`) — \`apply\` creates under the + manifest's names and refuses this flag. capture (adopt a hand-built instance into the age secret store): --generated force NAME to the \`pending-coolify-generated\` placeholder, @@ -379,6 +385,68 @@ export function renderAbsentTarget( ].join("\n"); } +// The third name a hand-built box does not share with you: the RESOURCE. +// +// `--project` and `--environment` are coordinates for finding the target; +// `--resource` is the coordinate for finding the things inside it. A box built +// by hand names its resources for humans reading a UI ("Incubator Stack v2"), +// while a manifest names them for machines reading a diff (`core`). Neither is +// wrong, and neither gets to overwrite the other — so the mapping is stated at +// the call site and applied at the boundary. +// +// It is deliberately NOT a manifest field: a manifest that recorded its own +// legacy names would carry a dead box's vocabulary forever, which is the exact +// failure #17 exists to prevent. This is an argument to a one-off read. +export function parseResourceAliases( + pairs: string[], + declared: string[], +): Record { + const alias: Record = {}; + for (const pair of pairs) { + const eq = pair.indexOf("="); + if (eq <= 0 || eq === pair.length - 1) { + throw new Error( + `--resource expects =, got "${pair}"`, + ); + } + const from = pair.slice(0, eq).trim(); + const to = pair.slice(eq + 1).trim(); + // A typo here would be silent and expensive: the alias would map nothing, + // the manifest's real resource would still be looked up under its own name, + // and the run would refuse (or capture) with no hint that the flag missed. + if (!declared.includes(from)) { + throw new Error( + [ + `--resource ${from}=${to}: the manifest declares no resource named "${from}"`, + "", + ` declares: ${declared.join(", ") || "(nothing)"}`, + "", + "The left side is the MANIFEST's name; the right side is what this box", + "calls the same thing.", + ].join("\n"), + ); + } + alias[from] = to; + } + return alias; +} + +// Rename live resources to the manifest's vocabulary, once, at the boundary. +// Everything downstream — computeDiff, classify, reconcile — then matches by +// name as it always has, and none of them needs to know a box was involved. +export function aliasLive( + live: T[], + alias: Record, +): Array { + const toManifestName = new Map( + Object.entries(alias).map(([manifest, box]) => [box, manifest]), + ); + return live.map((l) => { + const manifestName = toManifestName.get(l.name); + return manifestName ? { ...l, name: manifestName, sourceName: l.name } : l; + }); +} + // A live resource's env vars, by key. `real_value` is the decrypted one and // needs a token with read:sensitive; `value` is what a lesser token sees. // @@ -468,6 +536,7 @@ async function main(): Promise { state: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, + resource: { type: "string", multiple: true }, instance: { type: "string" }, "hostname-overlay": { type: "string" }, full: { type: "boolean", default: false }, @@ -479,6 +548,25 @@ async function main(): Promise { console.error(USAGE); return 2; } + // Up front, before a clone or a decrypt or a single call: `apply` creates + // resources under the MANIFEST's names, so an alias there would have to mean + // "adopt the existing resource called X instead" — updating in place rather + // than creating. That is a different operation, nobody has asked for it, and + // guessing at it would silently create a duplicate beside the very resource + // the operator was pointing at. + if (command === "apply" && (values.resource?.length ?? 0) > 0) { + console.error( + [ + "refusing to apply: --resource is a read-side coordinate", + "", + "It exists so `diff`, `capture` and `inventory` can READ a box whose", + "resources are named differently. `apply` creates resources under the", + "manifest's own names, so an alias here would have to mean 'adopt the", + "existing one instead' — which is not what this flag does.", + ].join("\n"), + ); + return 2; + } const stateDir = stateDirFrom(values.state); const repoShort = orgRepo.split("/")[1]; // The Coolify project name and the secrets-file key are different things @@ -553,7 +641,15 @@ async function main(): Promise { ); return 2; } - const live = lookup.found ? lookup.live : []; + const aliases = parseResourceAliases( + values.resource ?? [], + desired.map((d) => d.name), + ); + // Without this, a diff against a box that names things differently reports + // every manifest resource as "to create" and every live one as unknown — + // the D-237 lie by another route: a confident full-create plan that verified + // nothing, against a box that has all of it under other names. + const live = lookup.found ? aliasLive(lookup.live, aliases) : []; if (mode === "full") { for (const l of live) { l.env = await fetchEnv(client, l); @@ -595,6 +691,7 @@ async function main(): Promise { path: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, + resource: { type: "string", multiple: true }, instance: { type: "string" }, generated: { type: "string", multiple: true }, override: { type: "string", multiple: true }, @@ -682,9 +779,14 @@ async function main(): Promise { ); return 2; } + const aliases = parseResourceAliases( + values.resource ?? [], + manifestResources(checkout, envName).map((r) => r.name), + ); + const aliased = aliasLive(lookup.live, aliases); // Databases hold no manifest-templated env of their own — their URL is what // the APPS reference, and that name is generated, not captured. - const envBearing = lookup.live.filter((l) => l.kind !== "database"); + const envBearing = aliased.filter((l) => l.kind !== "database"); // Before reading a single env: does every resource the manifest requires a // secret FROM actually exist here? An absent resource reads back exactly // like one with no env vars set — every name it declares reports MISSING — @@ -755,6 +857,7 @@ async function main(): Promise { path: { type: "string" }, project: { type: "string" }, environment: { type: "string" }, + resource: { type: "string", multiple: true }, instance: { type: "string" }, }, }); @@ -800,13 +903,27 @@ async function main(): Promise { ); return 2; } + // With --resource, inventory stops reporting "these five are missing / these + // five are unknown" and starts reporting what you actually want to know: + // for each PAIR, which env keys differ. The report keeps the box's own name + // beside ours, because losing it would make the document unusable against + // the UI it describes. + const inventoryAliases = parseResourceAliases( + values.resource ?? [], + manifest.map((r) => r.name), + ); const live: LiveResource[] = []; - for (const l of lookup.live) { + for (const l of aliasLive(lookup.live, inventoryAliases)) { // Keys, never values — see renderInventory. Databases carry no env of // their own worth reconciling (their URL is what the apps reference). const envKeys = l.kind === "database" ? [] : Object.keys(await fetchEnv(client, l)); - live.push({ kind: l.kind, name: l.name, envKeys }); + live.push({ + kind: l.kind, + name: l.name, + envKeys, + ...(l.sourceName ? { sourceName: l.sourceName } : {}), + }); } console.log( renderInventory(reconcile(manifest, live), { diff --git a/src/inventory.ts b/src/inventory.ts index 9effe35..ede0d11 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -23,13 +23,20 @@ import type { ManifestResource } from "./resolve.js"; export type LiveResource = { kind: string; + // The MANIFEST's name for it, once --resource has aliased it. Without an + // alias, whatever the box calls it. name: string; + // What the box calls it, when that differs. Kept, and printed: a document + // that renamed the box's resources to our vocabulary and then never mentioned + // theirs would be unusable against the UI it describes. + sourceName?: string; envKeys: string[]; }; export type Matched = { kind: string; name: string; + sourceName?: string; // Declared by the manifest, absent from the box. manifestOnlyKeys: string[]; // On the box, and the manifest knows nothing about it. Either something the @@ -68,6 +75,7 @@ export function reconcile( matched.push({ kind: m.kind === l.kind ? m.kind : `${m.kind} / ${l.kind} on the box`, name: m.name, + ...(l.sourceName ? { sourceName: l.sourceName } : {}), manifestOnlyKeys: sorted(m.envKeys.filter((k) => !boxKeys.has(k))), boxOnlyKeys: sorted(l.envKeys.filter((k) => !manifestKeys.has(k))), sharedKeys: sorted(m.envKeys.filter((k) => boxKeys.has(k))), @@ -109,7 +117,11 @@ export function renderInventory( lines.push(" (nothing matched — see both lists below)"); } for (const m of rec.matched) { - lines.push(bullet(m.kind, m.name)); + lines.push( + m.sourceName + ? `${bullet(m.kind, m.name)} ← "${m.sourceName}" on the box` + : bullet(m.kind, m.name), + ); if (m.sharedKeys.length > 0) { lines.push(` both: ${m.sharedKeys.join(", ")}`); } @@ -150,6 +162,22 @@ export function renderInventory( (n, m) => n + m.manifestOnlyKeys.length + m.boxOnlyKeys.length, 0, ); + // Nothing matched, yet the box is full of resources: that is a NAMING gap, not + // an empty box — and it is the single most likely thing to be looking at you + // here. Say so, rather than leaving a reader to conclude the box has nothing + // (which is how "create everything" gets laundered into a pass). + if (rec.matched.length === 0 && rec.boxOnly.length > 0) { + lines.push( + "", + "NOTHING matched — and yet this box has resources. That is almost always a", + "naming difference, not an empty box: a box built by hand names things for a", + "human reading a UI, not for a manifest. Map them and re-run:", + "", + ...rec.manifestOnly.map( + (m) => ` --resource ${m.name}=""`, + ), + ); + } lines.push( "", drift === 0 diff --git a/test/read-side-cli.test.ts b/test/read-side-cli.test.ts index 734b19e..6f7facd 100644 --- a/test/read-side-cli.test.ts +++ b/test/read-side-cli.test.ts @@ -291,3 +291,97 @@ describe("cast inventory (#19)", () => { expect(r.output).toContain("inventory — heavy-duty/incubator staging"); }); }); + +describe("--resource (the third name, #23)", () => { + it("captures from a resource the box calls something else", async () => { + const f = fixture((await stubCoolify("Incubator Stack v2")).url); + const r = await run( + "capture", + [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + "--resource", + "core=Incubator Stack v2", + "--override", + "ADMIN_EMAIL", + ], + { + stdin: "staging\n", + env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" }, + }, + ); + expect(r.code).toBe(0); + expect(existsSync(f.store)).toBe(true); + }); + + it("shows the box's own name beside ours, and diffs the KEYS of the pair", async () => { + const f = fixture((await stubCoolify("Incubator Stack v2")).url); + const r = await run("inventory", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + "--resource", + "core=Incubator Stack v2", + ]); + expect(r.code).toBe(0); + // Matched — and the box's name is still there. A document that renamed the + // box's resources to our vocabulary and never mentioned theirs would be + // useless against the UI it describes. + expect(r.output).toContain('← "Incubator Stack v2" on the box'); + // The finding that only becomes visible once they are PAIRED: a var the box + // carries that the manifest has never heard of. + expect(r.output).toContain("box only:"); + expect(r.output).toContain("LEFTOVER_FROM_2019"); + }); + + it("tells you what to map, when nothing matched but the box is full", async () => { + const f = fixture((await stubCoolify("Incubator Stack v2")).url); + const r = await run("inventory", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + ]); + expect(r.code).toBe(0); + // Not "the box is empty" — which is how a full-create plan gets laundered + // into a pass. It is a naming gap, and the fix is printed. + expect(r.output).toContain("NOTHING matched"); + expect(r.output).toContain('--resource core=""'); + }); + + it("refuses an alias for a resource the manifest never declared", async () => { + const f = fixture((await stubCoolify("Incubator Stack v2")).url); + const r = await run("inventory", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + "--resource", + "cores=Incubator Stack v2", + ]); + // A typo here would be silent and expensive: the alias maps nothing, the + // real resource is looked up under its own name, and the run refuses with + // no hint that the flag missed. + expect(r.code).not.toBe(0); + expect(r.output).toContain('declares no resource named "cores"'); + expect(r.output).toContain("core"); + }); + + it("refuses --resource on apply — it is a read-side coordinate", async () => { + const f = fixture((await stubCoolify("Incubator Stack v2")).url); + const r = await run("apply", [ + ...base(f), + "--resource", + "core=Incubator Stack v2", + ]); + expect(r.code).toBe(2); + expect(r.output).toContain("read-side coordinate"); + }); +});