cast/src/envtemplate.ts
claude-hdb ea526d6598 feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
Add a ${resource:<name>.url} env-template ref that resolves to the internal URL
of a database the same manifest declares, read back from the live resource's
internal_db_url — never stored in the age store, never decrypted, never printed.
This deletes the two-pass generated-secret bootstrap for a database's own URL
rather than automating it: no placeholder, no stored copy to drift or overwrite,
and a rotated password is simply followed on the next apply.

Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff
time against databases already on the box (so a matching app shows no drift —
killing the "secret DATABASE_URL differs" noise that ran on every plan), and in
the executor at apply time against a database created earlier in the same run
(the from-nothing case; apply acts databases-before-applications, #45). The
unresolved sentinel is never written — the executor refuses, rather than write a
blank that boots the app pointed at nothing, and re-running once the database is
up resolves it as an ordinary update.

A ${resource:X.url} naming a database the manifest does not declare, or an
attribute other than .url, is a hard plan-time error refused by every verb that
opens a template (apply, diff, capture). generated_secrets and the two-pass
bootstrap remain for the residual class — a provider-generated value that
genuinely is not derivable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00

229 lines
9.9 KiB
TypeScript

// A ${resource:<name>.url} reference — the internal URL of a database THIS
// manifest declares, read back from the resource Coolify created. It is not a
// secret: a secret is something a human authored and the age store holds, while
// this is a FACT about a resource cast itself made, readable from the API that
// made it, at any time, for free (#60). So it never enters the store, never
// enters a terminal, and needs no age key to read back — it is resolved from the
// live box, not decrypted.
export type ResourceRef = { resource: string; attr: string };
// The value a derived var carries until it is resolved against the live
// resource. NEVER a legal thing to WRITE — the executor refuses it, the same
// fail-closed shape as the generated-secret placeholder — because a database URL
// that resolved to nothing boots every consumer pointed at nothing (the exact
// trap fetchGeneratedSources guards with its `never a fill of ""` rule).
//
// Collision-safe by construction, not by exotic bytes: this string only ever
// occupies a `derived` var's value, and such a value is only ever this sentinel
// or a real `postgres://…`/`redis://…` URL that fillDerivedEnv put there — so it
// cannot be mistaken for a resolved value, and `unresolvedDerived` gates on the
// `derived` flag anyway. (A plain string, no NUL byte: a NUL makes git treat the
// whole source file as binary and its diff unreviewable.)
export const DERIVED_UNRESOLVED = "cast:unresolved-derived-resource-url";
// `secret` is true for both a ${SECRET} and a resolved ${resource:…} — both are
// values that must never be printed. `derived` is set (and stays set after
// resolution) so the diff can say "derived from database X" rather than mistaking
// a routine URL change for a secret rotation, and so the executor knows which
// vars to resolve from the live resource before it writes.
export type ResolvedEnv = {
vars: Record<
string,
{ value: string; secret: boolean; derived?: ResourceRef }
>;
};
// A template line, parsed but not resolved: `ref` is set when the whole RHS is a
// single ${NAME} placeholder (a store secret); `resourceRef` when it is a single
// ${resource:<name>.attr} placeholder (a derived value). The two are mutually
// exclusive — a secret name is UPPER_SNAKE, a resource ref starts `resource:`.
export type TemplateVar = {
key: string;
rhs: string;
ref?: string;
resourceRef?: ResourceRef;
};
// ONE grammar, shared by both readers of a template — resolveTemplate (which
// needs the values) and templateRefs (which needs only the names). Keeping
// them on separate parsers would let the two drift, and a drift here is not
// cosmetic: `capture` would collect a different set of names than `apply` will
// later demand, which is precisely the "a name silently missed" failure the
// capture verb exists to remove.
function parseTemplate(text: string): TemplateVar[] {
const vars: TemplateVar[] = [];
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === "" || line.startsWith("#")) continue;
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
if (!m)
throw new Error(
`env template line ${i + 1}: expected KEY=value, got "${line}"`,
);
const [, key, rhs] = m;
const placeholder = rhs.match(/^\$\{([A-Z][A-Z0-9_]*)\}$/);
// A resource ref names a Coolify resource (lower-kebab/snake, as the manifest
// and Coolify spell it) and one dotted attribute. Only `.url` is derivable
// today; an unknown attr is not rejected HERE — parsing stays dumb — but at
// validation time (resolve.ts), where the message can name the declared
// resources, exactly as an undeclared-resource ref is.
const resource = rhs.match(
/^\$\{resource:([a-z0-9][a-z0-9_-]*)\.([a-z_]+)\}$/,
);
vars.push({
key,
rhs,
...(placeholder ? { ref: placeholder[1] } : {}),
...(resource
? { resourceRef: { resource: resource[1], attr: resource[2] } }
: {}),
});
}
return vars;
}
export function resolveTemplate(
text: string,
secrets: Record<string, string>,
): ResolvedEnv {
const vars: ResolvedEnv["vars"] = {};
for (const { key, rhs, ref, resourceRef } of parseTemplate(text)) {
// A derived value is resolved from the live resource, not the store, and not
// here — the resource may not exist yet (a from-nothing apply creates it in
// the same run). So it lands UNRESOLVED, carrying the ref; fillDerivedEnv
// fills it once a URL map is known, and the executor refuses to write one
// that never resolved. This is BEFORE the ref/store branch on purpose: a
// resource ref's RHS is not UPPER_SNAKE, so it would otherwise be mistaken
// for a literal and written through as the text "${resource:…}".
if (resourceRef) {
vars[key] = {
value: DERIVED_UNRESOLVED,
secret: true,
derived: resourceRef,
};
continue;
}
if (ref === undefined) {
vars[key] = { value: rhs, secret: false };
continue;
}
const value = secrets[ref];
if (value === undefined) {
throw new Error(`secret ${ref} (for ${key}) missing from the age store`);
}
vars[key] = { value, secret: true };
}
return { vars };
}
// Resolve every derived var whose resource now has a known URL, leaving the rest
// unresolved (a from-nothing apply has no live URL to resolve against until the
// database is created — the executor fills those, post-create). Pure, and the
// single place a derived value is turned into a real one, so the diff-time fill
// (against pre-existing live) and the apply-time fill (against a just-created
// resource) can never resolve the same ref two different ways.
//
// `urls` is keyed by the resource's MANIFEST name — the same name the ref
// carries. The caller keys it that way (see fillDesiredDerived / the executor):
// aliasing has already renamed live resources to the manifest's vocabulary by
// the time a URL map is built.
export function fillDerivedEnv(
env: ResolvedEnv,
urls: Record<string, string>,
): ResolvedEnv {
const vars: ResolvedEnv["vars"] = {};
for (const [key, v] of Object.entries(env.vars)) {
const url =
v.derived && v.derived.attr === "url"
? urls[v.derived.resource]
: undefined;
vars[key] =
url !== undefined && url !== ""
? { value: url, secret: true, derived: v.derived }
: v;
}
return { vars };
}
// The derived vars still holding the unresolved sentinel — what the executor
// must resolve from the live box before it can write this env, and what it
// refuses on if it cannot. Names the env key and the resource it derives from;
// never a value (there is none yet, and there never will be one to print).
export function unresolvedDerived(
env: ResolvedEnv,
): Array<{ key: string; resource: string }> {
return Object.entries(env.vars).flatMap(([key, v]) =>
v.derived && v.value === DERIVED_UNRESOLVED
? [{ key, resource: v.derived.resource }]
: [],
);
}
// The ${NAME} refs a template declares: the secret names the manifest requires,
// paired with the env var each one lands on. `capture` reads these to learn
// what to go and fetch — at capture time there is no store to resolve against
// yet, which is the whole point of the verb.
export function templateRefs(
text: string,
): Array<{ key: string; ref: string }> {
return parseTemplate(text).flatMap(({ key, ref }) =>
ref === undefined ? [] : [{ key, ref }],
);
}
// The ${resource:<name>.attr} refs a template declares — the derived edges. NOT
// returned by templateRefs above, and that separation is the point: a derived
// value is not a secret to be captured, so `capture` must never go looking for a
// store name called `resource:postgres.url`. resolve.ts reads these to validate
// each edge against the databases the manifest actually declares.
export function templateResourceRefs(
text: string,
): Array<{ key: string; resource: string; attr: string }> {
return parseTemplate(text).flatMap(({ key, resourceRef }) =>
resourceRef === undefined
? []
: [{ key, resource: resourceRef.resource, attr: resourceRef.attr }],
);
}
// Every env var key a template declares — refs and literals alike. `capture`
// only cares about the ${...} refs (the secrets); `inventory` needs all of them,
// because the question it answers is "what does the manifest put on this
// resource, and what does the box actually have?", and a literal the manifest
// sets (a feature flag, NODE_ENV) is just as much a difference as a secret.
//
// Same parser as everything else in this file — see parseTemplate.
export function templateKeys(text: string): string[] {
return parseTemplate(text).map(({ key }) => key);
}
// An environment may forbid variables by name pattern, declared as
// `environments.<env>.forbidden_var_patterns` in the state repo. The rule is
// PRESENCE, not value: a forbidden var set to "false" still refuses the apply,
// because a var that exists can be flipped on later in the Coolify UI without
// touching a manifest — "off" has to mean absent.
//
// The policy lives in the operator's private state, never in a product's
// manifest: a product-side change must not be able to lower its own guard.
export function assertEnvVarPolicy(
envName: string,
resolved: Record<string, ResolvedEnv>,
forbiddenPatterns: string[] | undefined,
): void {
if (!forbiddenPatterns?.length) return;
const patterns = forbiddenPatterns.map((p) => ({
src: p,
re: new RegExp(p),
}));
for (const [resource, env] of Object.entries(resolved)) {
for (const key of Object.keys(env.vars)) {
const hit = patterns.find((p) => p.re.test(key));
if (hit) {
throw new Error(
`refusing ${envName} apply: ${key} is present on ${resource} regardless of value — forbidden by forbidden_var_patterns /${hit.src}/ ("off" means absent, not false)`,
);
}
}
}
}