cast/src/capture.ts
Daniel Marin 0764f0018e
Merge pull request #56 from claude-hdb/fix/reserved-env-names
fix: never write an env var whose name Coolify injects itself (SOURCE_COMMIT, COOLIFY_*)
2026-07-14 23:54:15 +01:00

600 lines
23 KiB
TypeScript

import { assertNoReservedEnvNames, isReservedEnvName } from "./reserved.js";
import type { RequiredSecret } from "./resolve.js";
// What the manifest writes for a provider-generated name. Not the source box's
// value — that points at the SOURCE box's Postgres/Redis — and not an empty
// string, which would boot the app misconfigured. A literal that is obviously
// a placeholder, and that Coolify replaces when it creates the resource.
export const GENERATED_PLACEHOLDER = "pending-coolify-generated";
// Live env vars, per resource: resource name -> (env key -> value).
export type LiveEnvs = Record<string, Record<string, string>>;
export type Provenance = "captured" | "generated" | "overridden";
export type Site = { resource: string; key: string };
export type Disposition = {
ref: string;
provenance: Provenance;
// Never rendered. Kept here so the caller can encrypt it, and nowhere else.
value: string;
sites: Site[];
};
export type Classification = {
plan: Disposition[];
// Required by a template, absent from the source, and not dispositioned
// otherwise. Refuses the run: writing an empty value substitutes to nothing
// and the app boots misconfigured — the exact failure capture exists to
// remove, and one that looks entirely plausible from the outside.
missing: Array<{ ref: string; sites: Site[] }>;
// The same ref carrying DIFFERENT live values on two resources. cast cannot
// pick, and picking wrong is silent, so it refuses.
conflicts: Array<{ ref: string; values: Site[] }>;
};
// The manifest resources that no resource of that name exists for on the source.
//
// This is the absent-target lie (#12/D-237) one level deeper in the tree, and it
// fails in exactly the same way: a resource that is ABSENT reads back
// identically to one that is PRESENT with no env vars set. Every name it
// declares reports MISSING, the run refuses, and the message tells the operator
// to supply each of them with --override — which would "work", writing a
// perfectly valid store, while the real finding (the manifest and the box
// disagree about what this resource is called) is never discovered and the
// operator hand-carries values that were sitting right there under another name.
//
// So: refuse on the RESOURCE first, and only report per-name MISSING for
// resources that were actually found — where it means what it says.
export function absentResources(
required: RequiredSecret[],
liveNames: Iterable<string>,
): string[] {
const live = new Set(liveNames);
const declared = new Set(required.map((r) => r.resource));
return [...declared].filter((name) => !live.has(name)).sort();
}
export function renderAbsentResources(
absent: string[],
live: Array<{ kind: string; name: string }>,
ctx: { project: string; environment: string },
): string {
const width = Math.max(0, ...live.map((l) => l.kind.length));
return [
`refusing to capture: the manifest declares ${absent.length} resource(s) that do not exist here`,
"",
` looked in: project "${ctx.project}", environment "${ctx.environment}"`,
` looked for: ${absent.join(", ")}`,
" exists here:",
...(live.length > 0
? live.map((l) => ` ${l.kind.padEnd(width)} ${l.name}`)
: [" (nothing at all)"]),
"",
"A resource that is absent reads back exactly like one with no env vars set:",
"every name it declares reports MISSING, and --override would then have you",
"hand-carry values that are sitting right there under a different name. The",
"finding is not that the secrets are missing — it is that the manifest and this",
"box disagree about what these resources are called.",
"",
"`cast inventory` shows both sides. Then map them at the call site:",
"",
...absent.map(
(name) => ` --resource ${name}="<what this box calls it>"`,
),
].join("\n");
}
function groupByRef(required: RequiredSecret[]): Map<string, Site[]> {
const byRef = new Map<string, Site[]>();
for (const { ref, resource, key } of required) {
const sites = byRef.get(ref) ?? [];
sites.push({ resource, key });
byRef.set(ref, sites);
}
return byRef;
}
// Force disposition, never guess. Every name the manifest requires lands in
// exactly one of four buckets, and two of them stop the run.
//
// The mapping is deliberately NOT a mechanical dump of the source box: some
// entries encode migration decisions rather than facts about the source. A
// "capture everything" verb would be wrong in a handful of entries out of
// seventeen, silently — which is worse than being wrong in all of them.
export function classify(
required: RequiredSecret[],
generated: string[],
live: LiveEnvs,
overrides: Record<string, string>,
): Classification {
// The reserved-name rule, once more at the file (reserved.ts). Unreachable
// through the CLI — `requiredSecrets` refuses a manifest declaring one before
// capture is ever called — and kept anyway: it is the invariant, not the check
// that happens to enforce it today, and the next caller of classify will not
// have read resolve.ts. Captured, a SOURCE_COMMIT would put the source box's
// (usually empty) value into the age store, under a name a template already
// refers to — the trap laundered through the one artifact nobody can read.
assertNoReservedEnvNames(
required
.filter((r) => isReservedEnvName(r.key))
.map(({ resource, key }) => ({ resource, key })),
);
const generatedSet = new Set(generated);
const plan: Disposition[] = [];
const missing: Classification["missing"] = [];
const conflicts: Classification["conflicts"] = [];
for (const [ref, sites] of groupByRef(required)) {
// The operator's word beats both the manifest and the source box: this is
// the escape hatch for a value that must NOT be carried over (staging's
// ADMIN_EMAIL, where the source's value is a real founder and staging
// shares a Mailgun domain with prod).
if (ref in overrides) {
plan.push({
ref,
provenance: "overridden",
value: overrides[ref],
sites,
});
continue;
}
if (generatedSet.has(ref)) {
plan.push({
ref,
provenance: "generated",
value: GENERATED_PLACEHOLDER,
sites,
});
continue;
}
// Captured: read the live value off whichever resources declare it.
const found = sites
.map((s) => ({ site: s, value: live[s.resource]?.[s.key] }))
.filter((f): f is { site: Site; value: string } => f.value !== undefined);
if (found.length === 0) {
missing.push({ ref, sites });
continue;
}
const distinct = new Set(found.map((f) => f.value));
if (distinct.size > 1) {
conflicts.push({ ref, values: found.map((f) => f.site) });
continue;
}
plan.push({
ref,
provenance: "captured",
value: found[0].value,
sites,
});
}
return { plan, missing, conflicts };
}
const site = (s: Site) => `${s.resource}.${s.key}`;
// Names and provenance. NEVER values.
//
// The one thing printed that looks like a value is GENERATED_PLACEHOLDER,
// which is a literal constant in this file and carries no information about
// the source box. Everything else is a name the manifest already declares in
// plaintext, in a committed file.
export function renderCapturePlan(
c: Classification,
ctx: {
orgRepo: string;
env: string;
instance: string;
store: string;
recipient: string;
},
): string {
const lines = [
`capture plan — ${ctx.orgRepo} ${ctx.env}`,
"",
` source: instance ${ctx.instance} (live values read from it)`,
` store: ${ctx.store}`,
` recipient: ${ctx.recipient}`,
"",
];
const width = Math.max(
0,
...[...c.plan, ...c.missing, ...c.conflicts].map((d) => d.ref.length),
);
for (const d of c.plan) {
const where = d.sites.map(site).join(", ");
const note =
d.provenance === "generated"
? `${GENERATED_PLACEHOLDER}`
: d.provenance === "overridden"
? ` (from CAST_CAPTURE_${d.ref})`
: "";
lines.push(
` ${d.ref.padEnd(width)} ${d.provenance.padEnd(10)} ${where}${note}`,
);
}
for (const m of c.missing) {
lines.push(
` ${m.ref.padEnd(width)} MISSING required by ${m.sites.map(site).join(", ")}, absent from the source`,
);
}
for (const c2 of c.conflicts) {
lines.push(
` ${c2.ref.padEnd(width)} CONFLICT differs between ${c2.values.map(site).join(" and ")}`,
);
}
const counts = (["captured", "generated", "overridden"] as const)
.map((p) => [p, c.plan.filter((d) => d.provenance === p).length] as const)
.filter(([, n]) => n > 0)
.map(([p, n]) => `${n} ${p}`)
.join(", ");
lines.push(
"",
`${c.plan.length} name(s) to write${counts ? `: ${counts}` : ""}`,
);
if (c.missing.length > 0) {
lines.push(
"",
`refusing to write the store: ${c.missing.length} name(s) the manifest requires are not`,
"present on the source. An empty value substitutes to nothing and the app boots",
"misconfigured — plausibly, and silently. Supply each one with --override <NAME>",
"(its value is read from CAST_CAPTURE_<NAME>, never from argv), or fix the source.",
);
}
if (c.conflicts.length > 0) {
lines.push(
"",
`refusing to write the store: ${c.conflicts.length} name(s) carry different values on`,
"different resources of the source. The store holds one value per name, and cast",
"will not pick for you. Reconcile them on the source, or pin one with --override.",
);
}
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pass 2 — `capture --generated-only`
//
// Bootstrapping an environment that declares generated_secrets is two-pass BY
// CONSTRUCTION, and the two passes are not variations of one verb:
//
// pass 1 `capture` the store learns every name; the generated
// ones are placeheld, because their values do
// not exist yet — nothing has created them.
// (apply) Coolify creates the database and generates
// the real URL.
// pass 2 `capture --generated-only` the store learns THAT value.
//
// Between the two, the store's value for a generated name is a placeholder while
// the live value is real. Everything below exists to close that window without
// the operator hand-editing decrypted plaintext (which is how it was closed
// before: decrypt, edit two lines, re-encrypt, against prod, holding the key).
//
// The flag INVERTS classify()'s disposition rule and nothing else: the names in
// generated_secrets are the ones it fills, and every other name is left exactly
// as the store has it — byte for byte, never re-read from the box. A generated
// name is the only kind of name whose true value cast can find AFTER the fact,
// because it is the only kind the provider owns.
// ---------------------------------------------------------------------------
// A live database inside the project + environment being filled — the resource
// that OWNS a generated URL.
//
// `url` is `internal_db_url`, and it is read from the DATABASE, never from a
// consuming application's env. It never appears there: the app's env holds
// whatever the template resolved to, which at this point in the bootstrap is
// the placeholder itself. Reading the app back would faithfully capture the
// lie we are here to correct.
export type GeneratedSource = {
resource: string;
type: string;
url: string;
};
// One generated name, filled from the resource that owns it.
export type Fill = {
ref: string;
from: { resource: string; type: string };
// Never rendered. Kept here so the caller can encrypt it, and nowhere else —
// same contract as Disposition.value.
value: string;
};
export type GeneratedPlan = {
fills: Fill[];
// Names the store keeps, byte for byte. Printed as names so the operator can
// see the store is not being rewritten around them.
kept: string[];
// A generated name cast cannot attribute to exactly one database. Refuses:
// picking would be silent, and picking WRONG writes another box's credentials
// into this environment's store.
unmapped: Array<{ ref: string; why: string }>;
// A generated name whose store value is NOT the placeholder. Refuses without
// --force: it is already filled (someone ran pass 2, or set it by hand), and
// overwriting it is a silent rotation of a live credential.
occupied: string[];
// A generated name the store does not carry AT ALL. Refuses: pass 2 fills
// names, it does not invent them. A store missing one did not come from pass
// 1, and the name-count postcondition below could not hold anyway.
absent: string[];
// A store value that is still the placeholder and is NOT in the fill set —
// so this run would leave the store still lying. Refuses. This is the
// postcondition, checked BEFORE the write so it reads as a plan and not as an
// assertion failure over ciphertext already on disk.
stillPending: string[];
};
// Which database does a generated name come from?
//
// The manifest cannot say. `generated_secrets:` is a flat list of NAMES, and
// there is no edge anywhere in the data model from DATABASE_URL to the database
// that owns it — not in the manifest, not in the env template (which knows only
// `DATABASE_URL=${DATABASE_URL}`), and not on the box.
//
// So cast does not guess. It infers ONLY when the inference cannot be wrong —
// one generated name, one database, no other candidate — and otherwise refuses
// and makes the operator state the edge with --from. The temptation is to read
// the type out of the NAME (REDIS_URL → the redis one), and that is precisely
// the bug this verb must not have: a name-directed pick across a list of
// databases is #29 wearing a different hat, and it is wrong SILENTLY. The
// value it would write is a perfectly well-formed URL to somebody else's
// database.
export function resolveGeneratedSources(
generated: string[],
databases: GeneratedSource[],
from: Record<string, string>,
): {
mapping: Record<string, GeneratedSource>;
unmapped: Array<{ ref: string; why: string }>;
} {
const mapping: Record<string, GeneratedSource> = {};
const unmapped: Array<{ ref: string; why: string }> = [];
const byName = new Map(databases.map((d) => [d.resource, d]));
for (const ref of generated) {
const named = from[ref];
if (named !== undefined) {
const hit = byName.get(named);
if (!hit) {
unmapped.push({
ref,
why: `--from ${ref}=${named}, but no database named "${named}" exists in this project+environment`,
});
continue;
}
mapping[ref] = hit;
continue;
}
if (databases.length === 0) {
unmapped.push({
ref,
why: "no database exists in this project+environment to fill it from",
});
continue;
}
// The only inference that cannot be wrong: nothing else it could be.
if (generated.length === 1 && databases.length === 1) {
mapping[ref] = databases[0];
continue;
}
unmapped.push({
ref,
why: `${databases.length} databases here (${databases.map((d) => `${d.resource}:${d.type}`).join(", ")}) — cast will not pick by name`,
});
}
return { mapping, unmapped };
}
// Invert the disposition rule. `store` is the DECRYPTED current store; every
// name in it that is not being filled comes out the other side untouched.
export function planGenerated(
generated: string[],
store: Record<string, string>,
sources: Record<string, GeneratedSource>,
unmapped: Array<{ ref: string; why: string }>,
opts: { force: boolean } = { force: false },
): GeneratedPlan {
const generatedSet = new Set(generated);
const fills: Fill[] = [];
const occupied: string[] = [];
const absent: string[] = [];
for (const ref of generated) {
if (!(ref in store)) {
absent.push(ref);
continue;
}
const source = sources[ref];
// Already reported by resolveGeneratedSources; not also an "occupied".
if (!source) continue;
if (store[ref] !== GENERATED_PLACEHOLDER && !opts.force) {
occupied.push(ref);
continue;
}
fills.push({
ref,
from: { resource: source.resource, type: source.type },
value: source.url,
});
}
const filling = new Set(fills.map((f) => f.ref));
// A placeholder left standing in a name nobody is filling. The run would
// "succeed" and the store would still be a lie — so it refuses instead, and
// names the flag that fixes it. Generated names that failed to map or were
// refused as occupied are NOT reported here: they already have their own row,
// and one problem should be named once.
const stillPending = Object.keys(store)
.filter(
(k) =>
store[k] === GENERATED_PLACEHOLDER &&
!filling.has(k) &&
!generatedSet.has(k),
)
.sort();
// Everything the store keeps byte for byte. A name carrying a refusal above
// is not "kept" — it is the reason the run stops, and printing it in both
// rows would read as though cast had a plan for it.
const flagged = new Set([
...unmapped.map((u) => u.ref),
...occupied,
...stillPending,
]);
const kept = Object.keys(store)
.filter((k) => !filling.has(k) && !flagged.has(k))
.sort();
return { fills, kept, unmapped, occupied, absent, stillPending };
}
export function generatedPlanRefuses(p: GeneratedPlan): boolean {
return (
p.unmapped.length > 0 ||
p.occupied.length > 0 ||
p.absent.length > 0 ||
p.stillPending.length > 0
);
}
// Names, provenance and the resource a value came FROM. Never values.
//
// capture.ts's rule holds unchanged: the only value-shaped thing printed is
// GENERATED_PLACEHOLDER, a literal constant in this file — and here it is
// printed as the thing being REPLACED, which is the one fact about the store's
// current contents an operator needs in order to believe the plan.
export function renderGeneratedPlan(
p: GeneratedPlan,
ctx: {
orgRepo: string;
env: string;
instance: string;
store: string;
recipient: string;
project: string;
environment: string;
},
): string {
const lines = [
`capture --generated-only — ${ctx.orgRepo} ${ctx.env}`,
"",
` source: instance ${ctx.instance}, project "${ctx.project}", environment "${ctx.environment}"`,
` store: ${ctx.store}`,
` recipient: ${ctx.recipient}`,
"",
];
const width = Math.max(
0,
...[
...p.fills.map((f) => f.ref),
...p.kept,
...p.occupied,
...p.absent,
...p.unmapped.map((u) => u.ref),
].map((r) => r.length),
);
for (const f of p.fills) {
lines.push(
` ${f.ref.padEnd(width)} fill ${GENERATED_PLACEHOLDER}${f.from.resource} (${f.from.type}) internal_db_url`,
);
}
for (const k of p.kept) {
lines.push(` ${k.padEnd(width)} keep unchanged in the store`);
}
for (const u of p.unmapped) {
lines.push(` ${u.ref.padEnd(width)} UNMAPPED ${u.why}`);
}
for (const o of p.occupied) {
lines.push(
` ${o.padEnd(width)} OCCUPIED already holds a value that is not ${GENERATED_PLACEHOLDER}`,
);
}
for (const a of p.absent) {
lines.push(` ${a.padEnd(width)} ABSENT not in the store at all`);
}
for (const s of p.stillPending) {
lines.push(
` ${s.padEnd(width)} PENDING still ${GENERATED_PLACEHOLDER}, and nothing here fills it`,
);
}
lines.push(
"",
`${p.fills.length} name(s) to fill, ${p.kept.length} left exactly as the store has them`,
);
if (p.unmapped.length > 0) {
lines.push(
"",
`refusing to write the store: ${p.unmapped.length} generated name(s) cannot be attributed`,
"to exactly one database in this project+environment. Nothing in the manifest, the env",
"template or the box says which database a given name comes from, and cast will not",
"pick by name — a wrong pick writes another database's credentials into this store,",
"and it does it silently. State the edge:",
"",
...p.unmapped.map((u) => ` --from ${u.ref}=<database name>`),
);
}
if (p.occupied.length > 0) {
lines.push(
"",
`refusing to write the store: ${p.occupied.length} generated name(s) already hold a real`,
"value. Filling them would rotate a live credential — silently, and against whatever is",
"already running on that value. If Coolify's resource really was recreated and the store",
"is stale, say so with --force.",
);
}
if (p.absent.length > 0) {
lines.push(
"",
`refusing to write the store: ${p.absent.length} generated name(s) are not in the store at`,
"all. --generated-only FILLS names, it does not add them: the store it fills is the one",
"pass 1 wrote, and the name set must come out of this run exactly as it went in. Run",
"`cast capture` first, or check you are pointed at the right store.",
);
}
if (p.stillPending.length > 0) {
lines.push(
"",
`refusing to write the store: ${p.stillPending.length} name(s) would still hold the`,
`${GENERATED_PLACEHOLDER} literal after this run — the store would still be a lie, and`,
"the next apply would push that literal at a live app. If these are generated too,",
"declare them (manifest `generated_secrets:`, or --generated <NAME>).",
);
}
return lines.join("\n");
}
// The postcondition this verb EXISTS for, asserted against the store that was
// actually written — decrypted back off disk, not against the map cast held in
// memory a moment ago. In the hand-run procedure this was a line in a runbook
// ("assert 14 names / zero placeholders"), which is to say it was a step that
// could be, and eventually would be, skipped.
//
// Two claims, and they fail in opposite directions:
// - zero placeholders remain → the store no longer lies about any name
// - the name count is unchanged → and it did not lose one on the way
//
// A store that lost a name re-encrypts perfectly and reads back perfectly; the
// failure surfaces at the next apply, as a missing secret, in an environment
// whose plaintext nobody has any more.
export function assertGeneratedComplete(
before: Record<string, string>,
after: Record<string, string>,
): string[] {
const violations: string[] = [];
const pending = Object.keys(after)
.filter((k) => after[k] === GENERATED_PLACEHOLDER)
.sort();
if (pending.length > 0) {
violations.push(
`${pending.length} name(s) still hold the ${GENERATED_PLACEHOLDER} literal: ${pending.join(", ")}`,
);
}
const beforeNames = Object.keys(before).sort();
const afterNames = Object.keys(after).sort();
if (beforeNames.length !== afterNames.length) {
violations.push(
`the store went in with ${beforeNames.length} name(s) and came out with ${afterNames.length}`,
);
}
const lost = beforeNames.filter((n) => !(n in after));
const gained = afterNames.filter((n) => !(n in before));
if (lost.length > 0) violations.push(`names LOST: ${lost.join(", ")}`);
if (gained.length > 0) violations.push(`names ADDED: ${gained.join(", ")}`);
return violations;
}