cast/test/reserved.test.ts
claude-hdb 25768593c1 fix(diff): Coolify's own generated vars are not orphans (#87)
A prod box with zero drift could not make `cast diff` say clean: sixteen
lines of `live-only (orphan var — apply never removes)`, every one of them a
var Coolify MINTED — `SERVICE_FQDN_API` for a compose app's per-container
domains, `SERVICE_PASSWORD_POSTGRES`/`POSTGRES_*` for the one-click umami
service's bundled datastore. They held two resources permanently in `change`.

`remove-candidate` means "a live-only var the manifest does not declare;
apply never removes it; read it by eye". For a name cast did not put there,
cannot declare in any vocabulary, and will never remove, that is a category
error — and a report that can never say clean is how an operator learns to
stop reading it. #78's own Impact section made the argument: "an operator who
learns these always show change stops trusting the diff."

cast already knew: draft.ts has held this exact judgment since #27 and used
it to refuse copying these into a draft. diffEnv just never asked. So the
vocabulary moves to reserved.ts — which already owns "names the platform, not
the manifest, controls" — and both callers consult it.

TWO WIDTHS, deliberately, because over-matching is safe in a draft and unsafe
in a diff:

  - draft (WIDE): over-matching withholds a value for review — loud and
    recoverable. Under-matching copies the source box's DATABASE_URL into a
    new box that boots against the OLD box's database. It errs wide.
  - diff, applications (NARROW): over-matching HIDES a live-only var. A
    hand-left DATABASE_URL still pointing at a box nobody declares is the one
    orphan most worth printing — and it matches the wide rule. Probed against
    prod: the wide bucket on a real application held DATABASE_URL and
    REDIS_URL, both of them cast's OWN declared vars.
  - diff, services (WIDE): a Coolify service is a vendored bundle whose
    internals cast does not model — `type` + `service_domains` + an
    env_template is the whole vocabulary, and the rest is the template's.

Also fixes a real gap the #87 tests found: the pair-rule missed `POSTGRES_DB`
outright, because [POSTGRES, DB] is datastore + datastore with no connection
word. A db NAME is a connection coordinate like any other, so `DB` joins them
— it is exactly the var a one-click service mints for its bundled Postgres.

And corrects LiveEnvVar's comment: it still cited #79's "stale real_value, a
stored column Coolify does not refresh". That was false — an accessor cannot
go stale, and real_value tracks value on every row of a real box. The split
is still right (real_value is an ESCAPED rendering: 'true' is not true); only
its motivation was wrong. The drift it chased was #85's preview shadow.

Tests: an application carrying only SERVICE_* reads clean; a hand-left
DATABASE_URL on an application is STILL reported; a service carrying the
one-click template's wiring reads clean; a non-generated live-only var on a
service is STILL reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:56:34 +00:00

454 lines
16 KiB
TypeScript

import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { classify } from "../src/capture.js";
import { computeDiff, renderDiff } from "../src/diff.js";
import { type DraftProject, planDraft } from "../src/draft.js";
import { isReservedEnvName } from "../src/reserved.js";
import {
desiredFromManifest,
manifestResources,
requiredSecrets,
} from "../src/resolve.js";
import { SMOKE_KEEP_KEY, SMOKE_PROBE_KEY } from "../src/smoke.js";
// #50. Coolify injects SOURCE_COMMIT and the COOLIFY_* family itself, and SKIPS
// its own injection of a name the resource already carries a var of
// (ApplicationDeploymentJob.php, v4.1.2). So a var of that name SUPPRESSES the
// platform's value — and it fails GREEN: the deploy succeeds, the health check
// passes, and /version reports "unknown".
//
// The rule has to be true of CAST, not of one code path — every place cast
// touches an env var. This file tests all four of them together, because that
// joint property is the thing being claimed.
describe("the rule", () => {
it("reserves the names Coolify injects itself", () => {
for (const key of [
"SOURCE_COMMIT",
"COOLIFY_URL",
"COOLIFY_FQDN",
"COOLIFY_BRANCH",
"COOLIFY_RESOURCE_UUID",
"COOLIFY_CONTAINER_NAME",
"COOLIFY_ANYTHING_AT_ALL",
]) {
expect(isReservedEnvName(key), key).toBe(true);
}
});
// The rule is exactly two shapes. A name that merely LOOKS adjacent is a
// manifest's own business — over-reaching here refuses a manifest that was
// right, which is the one way this rule can do harm.
it("reserves nothing else", () => {
for (const key of [
"SOURCE_COMMIT_SHA",
"MY_SOURCE_COMMIT",
"COOLIFYISH",
"SERVICE_FQDN_UMAMI",
"DATABASE_URL",
"NODE_ENV",
]) {
expect(isReservedEnvName(key), key).toBe(false);
}
});
});
// --- resolve / apply: REFUSE ---------------------------------------------------
function checkout(template: string, envName = "staging"): string {
const dir = mkdtempSync(join(tmpdir(), "infra-reserved-"));
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
${envName}:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
port: 3000
domains: []
env_template: core.${envName}.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", `core.${envName}.env.template`),
template,
);
return dir;
}
describe("resolve / apply — a manifest that declares a reserved name is refused", () => {
it("refuses before anything is written, naming the var and the consequence", () => {
const dir = checkout("PORT=3000\nSOURCE_COMMIT=${SOURCE_COMMIT}\n");
expect(() =>
desiredFromManifest(dir, "staging", { SOURCE_COMMIT: "abc123" }),
).toThrow(/SOURCE_COMMIT/);
try {
desiredFromManifest(dir, "staging", { SOURCE_COMMIT: "abc123" });
} catch (e) {
const msg = String(e);
expect(msg).toMatch(/refusing/);
expect(msg).toMatch(/core/); // which resource
expect(msg).toMatch(/SUPPRESSES/); // what it does
expect(msg).toMatch(/GREEN/); // and how it fails
expect(msg).toMatch(/version/);
}
});
// The whole trap, in one test. An EMPTY SOURCE_COMMIT is not a var that does
// nothing — it is the var that suppresses the injection most invisibly, and it
// is the one the real box actually carried. Presence, not value: the same rule
// forbidden_var_patterns already holds to.
it("refuses an EMPTY literal — presence, not value", () => {
const dir = checkout("PORT=3000\nSOURCE_COMMIT=\n");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/SOURCE_COMMIT/,
);
});
it("refuses the COOLIFY_* family too", () => {
const dir = checkout("COOLIFY_URL=https://app.example.com\n");
expect(() => desiredFromManifest(dir, "staging", {})).toThrow(
/COOLIFY_URL/,
);
});
// Not just apply: every verb that reads the manifest. Refusing in one and
// reporting in another would let `capture` write a store for a manifest
// `apply` is guaranteed to refuse — a green run that promises a red one.
it("refuses on the capture path (requiredSecrets) and the inventory path (manifestResources)", () => {
const dir = checkout("SOURCE_COMMIT=${SOURCE_COMMIT}\n");
expect(() => requiredSecrets(dir, "staging")).toThrow(/SOURCE_COMMIT/);
expect(() => manifestResources(dir, "staging")).toThrow(/SOURCE_COMMIT/);
});
it("leaves an ordinary manifest alone", () => {
const dir = checkout("PORT=3000\nMG=${MG}\n");
const { desired } = desiredFromManifest(dir, "staging", { MG: "v" });
expect(desired).toHaveLength(1);
});
});
// --- capture: NEVER STORE ONE --------------------------------------------------
describe("capture — classify refuses a reserved name at the file", () => {
// Unreachable through the CLI (requiredSecrets refuses first) and asserted
// anyway: the invariant is "cast never carries one", not "the CLI happens to
// check first". A captured SOURCE_COMMIT would sit in the age store — the one
// artifact a reviewer cannot read.
it("refuses rather than reading the live value into the store", () => {
expect(() =>
classify(
[{ ref: "SOURCE_COMMIT", resource: "core", key: "SOURCE_COMMIT" }],
[],
{ core: { SOURCE_COMMIT: "" } },
{},
),
).toThrow(/SOURCE_COMMIT/);
});
});
// --- draft: NEVER COPY ONE -----------------------------------------------------
const draftCtx = {
env: "prod",
instance: "box-b",
baseUrl: "https://coolify.example.com",
team: { id: 0, name: "Root Team" },
server: "box-b",
recipient: "age1example",
generatedAt: "2026-07-13T00:00:00.000Z",
};
// The live box that set this whole thing off: a working app carrying an orphan,
// EMPTY SOURCE_COMMIT. Before #50, isProviderGenerated was the only filter on
// what a live var becomes in a drafted manifest — and SOURCE_COMMIT splits to
// [SOURCE, COMMIT]: no SERVICE_ prefix, no datastore word, no connection word.
// So it was captured verbatim, and drafting a working box reproduced the trap in
// the new box's manifest.
const suppressingBox = (): DraftProject => ({
name: "Incubator",
coolifyEnv: "staging",
resources: [
{
kind: "application",
name: "core",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
ports_exposes: "3000",
fqdn: "https://app.example.com",
},
env: {
SOURCE_COMMIT: "",
COOLIFY_BRANCH: "main",
MAILGUN_KEY: "key-abc123",
},
},
],
unreadable: [],
otherEnvironments: [],
});
describe("draft — a reserved name is suppressed, not copied", () => {
const plan = planDraft([suppressingBox()], draftCtx);
const template = plan.files.find((f) => f.path.endsWith(".env.template"));
it("keeps it out of the emitted env template", () => {
expect(template?.content).toContain("MAILGUN_KEY=");
expect(template?.content).not.toContain("SOURCE_COMMIT");
expect(template?.content).not.toContain("COOLIFY_BRANCH");
});
it("keeps it out of the age store", () => {
const store = plan.stores[0];
expect(Object.keys(store.vars)).toEqual(["MAILGUN_KEY"]);
});
it("dispositions it as `suppressed` rather than dropping it in silence", () => {
const d = plan.dispositions.find((x) => x.ref === "SOURCE_COMMIT");
expect(d?.provenance).toBe("suppressed");
expect(d?.sites).toEqual(["core.SOURCE_COMMIT"]);
expect(
plan.dispositions.find((x) => x.ref === "COOLIFY_BRANCH")?.provenance,
).toBe("suppressed");
expect(
plan.dispositions.find((x) => x.ref === "MAILGUN_KEY")?.provenance,
).toBe("captured");
});
// UNCAPTURED.md exists precisely so that what cast declines to carry is stated
// out loud rather than dropped — and this entry has to say two things: it is
// not in your draft, AND it is a live bug on the box you drafted from.
it("names it in UNCAPTURED.md, with the consequence", () => {
const uncaptured = plan.uncaptured.find(
(u) => u.setting === "env var SOURCE_COMMIT",
);
expect(uncaptured).toBeDefined();
expect(uncaptured?.detail).toMatch(/SUPPRESSES/);
expect(uncaptured?.detail).toMatch(/NOT in this draft/);
const md = plan.files.find((f) => f.path === "UNCAPTURED.md");
expect(md?.content).toContain("SOURCE_COMMIT");
});
// A resource whose only vars were reserved has no template to point at, and
// must not gain an env_template line for a file that does not exist.
it("emits no env template at all when every var was reserved", () => {
const box = suppressingBox();
box.resources[0].env = { SOURCE_COMMIT: "" };
const p = planDraft([box], draftCtx);
expect(p.files.some((f) => f.path.endsWith(".env.template"))).toBe(false);
expect(p.stores).toHaveLength(0);
const manifest = p.files.find((f) => f.path.endsWith("manifest.yaml"));
expect(manifest?.content).not.toContain("env_template");
});
});
// --- diff: A FINDING, NOT AN ORPHAN VAR ----------------------------------------
const desiredApp = {
kind: "application" as const,
name: "core",
fields: { build_pack: "nixpacks" },
env: { vars: { PORT: { value: "3000", secret: false } } },
};
// Wrap plain live values as Coolify's {value, realValue} pairs (here the two
// agree); computeDiff reads them per LiveEnvVar. See diffEnv / #78.
const liveEnv = (
m: Record<string, string>,
): Record<string, { value: string }> =>
Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { value: v }]));
const liveApp = (env: Record<string, string>) => ({
kind: "application" as const,
name: "core",
uuid: "u1",
fields: { build_pack: "nixpacks" },
env: liveEnv(env),
});
describe("diff — a reserved name on a live box is a finding", () => {
it("is promoted OUT of the remove-candidate orphan list", () => {
const r = computeDiff(
[desiredApp],
[liveApp({ PORT: "3000", SOURCE_COMMIT: "" })],
"full",
);
// The category whose documented meaning is "apply never removes these; read
// them by eye". This is not cosmetic residue, so it must not be filed as it.
const orphanVars = r.changes.flatMap((c) =>
c.envDiffs.filter((e) => e.state === "remove-candidate"),
);
expect(orphanVars).toEqual([]);
expect(r.reserved).toEqual([
{ kind: "application", name: "core", key: "SOURCE_COMMIT" },
]);
});
it("is not clean — the box is deploying green and reporting the wrong commit", () => {
const r = computeDiff(
[desiredApp],
[liveApp({ PORT: "3000", SOURCE_COMMIT: "" })],
"full",
);
expect(r.clean).toBe(false);
const out = renderDiff(r);
expect(out).toMatch(/FINDING/);
expect(out).toMatch(/DELETE IT/);
expect(out).toMatch(/SUPPRESSES/);
expect(out).toMatch(/reserved-name FINDING\(s\)/);
// apply never deletes — cast reports it, the human removes it in the UI.
expect(out).toMatch(/never deletes/);
});
// A reserved var suppresses the injection whether or not cast has ever heard
// of the resource carrying it — so the scan is over the LIVE side, not over
// the resources the manifest happens to declare.
it("finds one on an orphan resource, which no change entry covers", () => {
const r = computeDiff(
[desiredApp],
[
liveApp({ PORT: "3000" }),
{
kind: "application" as const,
name: "nobody-declared-me",
uuid: "u2",
fields: {},
env: { COOLIFY_URL: "https://stale.example.com" },
},
],
"full",
);
expect(r.reserved).toEqual([
{
kind: "application",
name: "nobody-declared-me",
key: "COOLIFY_URL",
},
]);
expect(r.clean).toBe(false);
});
it("finds none in structural mode, where no env var was read at all", () => {
const r = computeDiff([desiredApp], [liveApp({})], "structural");
expect(r.reserved).toEqual([]);
});
it("stays clean on a box with no reserved names", () => {
const r = computeDiff([desiredApp], [liveApp({ PORT: "3000" })], "full");
expect(r.clean).toBe(true);
expect(renderDiff(r)).not.toMatch(/FINDING/);
});
});
// --- diff: a name Coolify MINTED is not an orphan either (#87) -----------------
//
// The other half of the same rule. A reserved name is not a remove-candidate
// because it is consequential; a generated one is not a remove-candidate because
// it is not cast's at all. The width differs by kind, and that is the part worth
// pinning: an application that swallowed a live-only DATABASE_URL would hide the
// single orphan most worth printing.
const liveService = (env: Record<string, string>) => ({
kind: "service" as const,
name: "umami",
uuid: "s1",
fields: { type: "umami" },
env: liveEnv(env),
});
const desiredService = {
kind: "service" as const,
name: "umami",
fields: { type: "umami" },
env: { vars: {} },
};
describe("diff — Coolify's own generated vars are not orphans", () => {
// The exact prod box: six magic vars minted for core's per-container domains,
// and nothing else live-only. It held the report in `change` forever.
it("reads CLEAN on an application carrying only SERVICE_* magic vars", () => {
const r = computeDiff(
[desiredApp],
[
liveApp({
PORT: "3000",
SERVICE_URL_API: "https://api.example.com",
SERVICE_FQDN_API: "https://api.example.com",
SERVICE_URL_ADMIN: "https://admin.example.com",
SERVICE_FQDN_ADMIN: "https://admin.example.com",
SERVICE_URL_INTAKE: "https://apply.example.com",
SERVICE_FQDN_INTAKE: "https://apply.example.com",
}),
],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.clean).toBe(true);
});
// The hazard the NARROW rule exists to preserve. A hand-left DATABASE_URL is a
// connection string still pointing at a box nobody declares any more — the
// exact poison draft.ts refuses to copy. The wide rule matches it; an
// application must NOT use the wide rule.
it("still reports a hand-left DATABASE_URL on an application", () => {
const r = computeDiff(
[desiredApp],
[liveApp({ PORT: "3000", DATABASE_URL: "postgres://old-box/app" })],
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "DATABASE_URL", state: "remove-candidate", secret: false },
]);
expect(r.clean).toBe(false);
});
// A service is a vendored bundle cast does not model: the one-click template's
// own POSTGRES_* wiring is the bundle's, not an orphan. Only the WIDE rule
// catches these — they carry no SERVICE_ prefix.
it("reads CLEAN on a service carrying the one-click template's own wiring", () => {
const r = computeDiff(
[desiredService],
[
liveService({
SERVICE_FQDN_UMAMI_3000: "https://analytics.example.com:3000",
SERVICE_PASSWORD_POSTGRES: "generated",
SERVICE_PASSWORD_64_UMAMI: "generated",
POSTGRES_USER: "umami",
POSTGRES_PASSWORD: "generated",
POSTGRES_DB: "umami",
}),
],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.clean).toBe(true);
});
// …but the width must not become "a service reports nothing". A name that is
// neither Coolify-minted nor a datastore coordinate is still somebody's doing.
it("still reports a non-generated live-only var on a service", () => {
const r = computeDiff(
[desiredService],
[liveService({ POSTGRES_DB: "umami", LEGACY_FLAG: "on" })],
"full",
);
expect(r.changes[0].envDiffs).toEqual([
{ key: "LEGACY_FLAG", state: "remove-candidate", secret: false },
]);
});
});
// --- smoke: it writes an env var too -------------------------------------------
describe("smoke — the probe it writes can never be a reserved name", () => {
it("picks names outside the reserved space", () => {
expect(isReservedEnvName(SMOKE_KEEP_KEY)).toBe(false);
expect(isReservedEnvName(SMOKE_PROBE_KEY)).toBe(false);
});
});