Merge pull request #23 from claude-hdb/feat/resource-aliases

--resource: the third name a hand-built box does not share with you
This commit is contained in:
Daniel Marin 2026-07-13 20:02:27 +01:00 committed by GitHub
commit f7670938ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 258 additions and 7 deletions

View file

@ -221,7 +221,15 @@ yours:
| --- | --- |
| `--project <name>` | the project isn't named after the repo (`Incubator`, not `incubator`) |
| `--environment <name>` | 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 <manifest>=<live>` | 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,

View file

@ -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}="<what this box calls it>"`,
),
].join("\n");
}

View file

@ -80,6 +80,12 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
\`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 <manifest-name>=<live-name>
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 <NAME> 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<string, string> {
const alias: Record<string, string> = {};
for (const pair of pairs) {
const eq = pair.indexOf("=");
if (eq <= 0 || eq === pair.length - 1) {
throw new Error(
`--resource expects <manifest-name>=<live-name>, 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<T extends { name: string }>(
live: T[],
alias: Record<string, string>,
): Array<T & { sourceName?: string }> {
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<number> {
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<number> {
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<number> {
);
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<number> {
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<number> {
);
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<number> {
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<number> {
);
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), {

View file

@ -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}="<what this box calls it>"`,
),
);
}
lines.push(
"",
drift === 0

View file

@ -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="<what this box calls it>"');
});
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");
});
});