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 { 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 } } },
|
|
|
|
|
};
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|