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-19 23:38:53 +00:00
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
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";
|
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-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
|
|
|
|
|
// #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 {
|
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-19 23:38:53 +00:00
|
|
|
const dir = tmp("infra-reserved-");
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
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 } } },
|
|
|
|
|
};
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
// 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 }]));
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
const liveApp = (env: Record<string, string>) => ({
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { build_pack: "nixpacks" },
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv(env),
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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:10 +00:00
|
|
|
// --- 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 },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
fix: never write an env var whose name Coolify injects itself (#50)
Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's
runtime environment itself, and SKIPS its own injection of a name the resource
already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 —
`->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that
name therefore SUPPRESSES the platform's value. An empty one suppresses it
just as completely: presence, not value.
And it fails green — the deploy succeeds, health checks pass, and the only
symptom is /version reporting "unknown", the endpoint a production cutover is
gated on (D-266).
The rule is now a property of cast, not of one code path. A new src/reserved.ts
owns it, and every place cast touches an env var honors it:
- resolve — every manifest read (desiredFromManifest, requiredSecrets,
manifestResources) refuses a template declaring a reserved name, before any
write. So apply, diff, capture and inventory all refuse identically.
- draft — a reserved name read off a live box gets its own provenance,
`suppressed`: out of the template, out of the age store, its live value read
into no artifact, and named in UNCAPTURED.md with the consequence.
- diff — promoted out of the remove-candidate orphan list ("apply never removes
these; read them by eye") and printed as a FINDING with its consequence. Not
clean. apply still never deletes: cast reports, the human removes it.
- capture (classify) and cli (syncEnv) carry the same assertion at the file and
at the wire — unreachable through the CLI today, and kept because the
invariant is "cast never writes one", not "the CLI happens to check first".
- smoke writes an env var too; its probe names are asserted outside the space.
The rule lives in cast's code, NOT beside forbidden_var_patterns in private
state: that one is policy an environment may set for itself, this one is a fact
about Coolify, true on every box — nothing a manifest change could lower.
19 tests in test/reserved.test.ts, one per path.
Closes #50.
2026-07-14 22:29:23 +00:00
|
|
|
// --- 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);
|
|
|
|
|
});
|
|
|
|
|
});
|