cast/test/reserved.test.ts
dan-claude-bot 7f0e886851 fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.

The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.

Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.

Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.

Refs #117
2026-07-21 12:58:14 +00:00

454 lines
16 KiB
TypeScript

import { mkdirSync, writeFileSync } from "node:fs";
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";
import { tmp } from "./helpers/tmp.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 = tmp("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);
});
});