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"); + }); +});