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
// Names whose meaning belongs to the PLATFORM, and which cast therefore never
// writes, never copies, and never lets a manifest declare.
//
// Coolify injects a set of values into an application's runtime environment
// itself — `SOURCE_COMMIT`, and the `COOLIFY_*` family (`COOLIFY_URL`,
// `COOLIFY_FQDN`, `COOLIFY_BRANCH`, `COOLIFY_RESOURCE_UUID`,
// `COOLIFY_CONTAINER_NAME`). It does so behind one guard, and that guard is the
// entire reason this file exists — app/Jobs/ApplicationDeploymentJob.php
// (coollabsio/coolify v4.1.2), in generate_coolify_env_variables:
//
// if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
// if (! is_null($this->commit)) {
// $coolify_envs->put('SOURCE_COMMIT', $this->commit);
// } else {
// $coolify_envs->put('SOURCE_COMMIT', 'unknown');
// }
// }
//
// (v4.1.2, lines 2994-3001; the same `->isEmpty()` shape guards each COOLIFY_*
// name at 3002-3028, and again on the preview branch at 2950-2984.)
//
// Coolify SKIPS its own injection of a name when the application already carries
// an env var of that name. So an application-level `SOURCE_COMMIT` does not
// merely fail to help — it **suppresses** the value Coolify would otherwise have
// provided. An EMPTY one suppresses it exactly as completely: presence is the
// whole test, `isEmpty()` being asked of the collection of vars, never of the
// value.
//
// And it fails GREEN. The deploy succeeds, the health check passes, the
// container runs — and the only symptom is that `/version`, which reads
// `process.env.SOURCE_COMMIT` at request time, reports `unknown`. That is the
// endpoint a production cutover is gated on. (D-266.)
//
// WHY THIS IS IN CAST'S CODE AND NOT IN THE STATE FILE. `forbidden_var_patterns`
// (envtemplate.ts) is the neighbouring rule and looks like the obvious home for
// this one. It is not. That rule is POLICY — an environment's own choice about
// its own vars, which prod may set harder than staging, and which therefore
// lives in the operator's private state precisely so a product-side change
// cannot lower its own guard. This rule is a FACT ABOUT COOLIFY: true on every
// box, in every environment, for every project. There is no environment in which
// declaring `SOURCE_COMMIT` is correct, so there must be no file in which it can
// be permitted.
//
// Not to be confused with cast's OWN config vars — `COOLIFY_BASE_URL`,
// `COOLIFY_ACCESS_TOKEN`, `COOLIFY_READ_ONLY` (config.ts). Those are read out of
// the operator's local instance file and are never written to a resource. The
// namespace collides; the meaning does not. Nothing here applies to them.
// Exactly the two shapes, and no more. Widening this set is not free: every name
// added here is a name cast will REFUSE to carry, so a wrong entry breaks a
// manifest that was right. Add one only with the guard in Coolify's source that
// justifies it.
export const RESERVED_EXACT : readonly string [ ] = [ "SOURCE_COMMIT" ] ;
export const RESERVED_PREFIX = /^COOLIFY_/ ;
export function isReservedEnvName ( key : string ) : boolean {
return RESERVED_EXACT . includes ( key ) || RESERVED_PREFIX . test ( key ) ;
}
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
// --- Generated: the names Coolify MINTS ---------------------------------------
//
// The second family of platform-owned names, and the distinction from the
// reserved ones above is why this is a separate export rather than more entries
// in them:
//
// RESERVED declaring one SUPPRESSES the platform's value, so cast refuses
// the manifest outright (assertNoReservedEnvNames). The danger runs
// manifest -> box.
// GENERATED Coolify MINTS these per instance — a compose app's per-container
// domains (SERVICE_FQDN_API), a one-click service's bundled
// datastore credentials (SERVICE_PASSWORD_POSTGRES). The danger runs
// the other way, box -> report: a LIVE one is not an orphan var cast
// should offer to remove, it is the platform's own. Calling it a
// `remove-candidate` is a category error, and sixteen such lines on
// a correct box is how an operator learns to stop reading the diff
// (#87 — the argument #78's own Impact section already made:
// "an operator who learns these always show change stops trusting
// the diff").
//
// TWO WIDTHS, ON PURPOSE. The asymmetry is load-bearing, and a future reader who
// "unifies" these will silently blind the diff:
//
// isCoolifyGeneratedEnvName NARROW — a prefix rule, nothing heuristic.
// isProviderGeneratedEnvName WIDE — the prefix, OR a name carrying both a
// datastore word and a connection word
// (DATABASE_URL, DB_HOST, POSTGRES_PASSWORD).
//
// Over-matching is SAFE in a draft and UNSAFE in a diff:
//
// - draft (WIDE): over-matching withholds a VALUE and lists it for disposition
// — noisy, recoverable, loud. Under-matching copies the source box's
// DATABASE_URL into a new box that comes up working against the OLD box's
// database, and nobody finds out until the old box is deleted. Silent and
// unrecoverable, so it errs wide (see draft.ts).
// - diff (NARROW): over-matching HIDES a live-only var. A hand-left
// DATABASE_URL still pointing at a box nobody declares any more is the single
// orphan most worth printing — and it matches the wide rule. Not theoretical:
// probed against prod, the wide bucket on a real application held exactly
// DATABASE_URL and REDIS_URL, both of them cast's OWN declared vars (#87).
export const COOLIFY_GENERATED =
/^SERVICE_(FQDN|URL|USER|PASSWORD|BASE64|REALBASE64)(_|$)/ ;
// A name that carries both a datastore word and a connection word is a
// connection coordinate for a datastore the PROVIDER creates. Kept here rather
// than in draft.ts so the two callers share one vocabulary and differ only in
// the width they ask for.
const DATASTORE_WORDS = new Set ( [
"DATABASE" ,
"DB" ,
"POSTGRES" ,
"POSTGRESQL" ,
"PG" ,
"MYSQL" ,
"MARIADB" ,
"MONGO" ,
"MONGODB" ,
"REDIS" ,
"VALKEY" ,
"KEYDB" ,
"DRAGONFLY" ,
"CLICKHOUSE" ,
] ) ;
// The db NAME is a connection coordinate like any other — you cannot connect
// without it — so `DB` sits in BOTH sets, and that is not a mistake: it is a
// datastore word in `DB_HOST` and a connection word in `POSTGRES_DB`. Without it
// the pair-rule missed `POSTGRES_DB` outright ([POSTGRES, DB] is datastore +
// datastore, no connection word), which is exactly the var a one-click service
// mints for its bundled Postgres. Found by the #87 tests, on the real umami.
const CONNECTION_WORDS = new Set ( [
"URL" ,
"URI" ,
"DSN" ,
"HOST" ,
"HOSTNAME" ,
"PORT" ,
"PASSWORD" ,
"PASS" ,
"USER" ,
"USERNAME" ,
"DB" ,
] ) ;
export function isCoolifyGeneratedEnvName ( key : string ) : boolean {
return COOLIFY_GENERATED . test ( key ) ;
}
export function isProviderGeneratedEnvName ( key : string ) : boolean {
if ( isCoolifyGeneratedEnvName ( key ) ) return true ;
const words = key . split ( "_" ) ;
return (
words . some ( ( w ) = > DATASTORE_WORDS . has ( w ) ) &&
words . some ( ( w ) = > CONNECTION_WORDS . has ( w ) )
) ;
}
// The live-only names a DIFF must not offer to remove (#87), and the one place
// the width is chosen.
//
// NARROW for an application: cast models an application's env completely — every
// var it should carry is in an env template — so an undeclared datastore var
// there is a hand-left one, and printing it is the whole point.
//
// WIDE for a service: a Coolify service is a VENDORED BUNDLE whose internals cast
// does not model at all. Its manifest entry is `type` + `service_domains` + an
// env_template; everything else on it (POSTGRES_USER, POSTGRES_DB, the one-click
// template's own wiring) belongs to the bundle. cast cannot meaningfully call
// those orphans — it did not put them there, it will not remove them, and it has
// no vocabulary to declare them in.
//
// `kind` is spelled structurally rather than imported as ResourceKind: diff.ts
// imports this module, so importing its type back would be a cycle.
export function isPlatformOwnedEnvName (
key : string ,
kind : "application" | "database" | "service" ,
) : boolean {
return kind === "service"
? isProviderGeneratedEnvName ( key )
: isCoolifyGeneratedEnvName ( key ) ;
}
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
// One sentence, wherever a reserved name has to be reported rather than refused
// (`diff` on a live box, `inventory --emit-draft`'s UNCAPTURED.md). Whatever the
// verb, the consequence is the same sentence — a reader who has met it once in a
// diff recognizes it in a draft.
export function reservedConsequence ( key : string ) : string {
return ` ${ key } is injected by Coolify itself at runtime, and Coolify SKIPS its own injection when the resource already carries a var of that name (ApplicationDeploymentJob.php, v4.1.2). A var of this name — even an EMPTY one — SUPPRESSES the platform's value. The deploy stays green and /version reports "unknown". ` ;
}
export type ReservedHit = { resource : string ; key : string } ;
export function reservedHits (
resource : string ,
keys : Iterable < string > ,
) : ReservedHit [ ] {
const hits : ReservedHit [ ] = [ ] ;
for ( const key of keys ) {
if ( isReservedEnvName ( key ) ) hits . push ( { resource , key } ) ;
}
return hits ;
}
export function renderReservedRefusal ( hits : ReservedHit [ ] ) : string {
const width = Math . max ( . . . hits . map ( ( h ) = > h . resource . length ) ) ;
return [
` refusing this manifest: ${ hits . length } env var(s) declare a name Coolify injects itself ` ,
"" ,
. . . hits . map ( ( h ) = > ` ${ h . resource . padEnd ( width ) } ${ h . key } ` ) ,
"" ,
"Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's" ,
"runtime environment itself — and it SKIPS its own injection of a name the" ,
"resource already carries a var of (ApplicationDeploymentJob.php, v4.1.2). A" ,
"declared var of that name therefore does not merely fail to help: it SUPPRESSES" ,
"the value Coolify would otherwise have provided." ,
"" ,
"Presence, not value. An empty one suppresses it exactly as completely — the same" ,
'rule forbidden_var_patterns already holds to, for the same reason: "off" means' ,
"absent, not empty." ,
"" ,
"And it fails GREEN. The deploy succeeds, the health check passes, and the only" ,
'symptom is /version reporting "unknown" — the endpoint a production cutover is' ,
"gated on." ,
"" ,
"Delete the line from the env template. There is nothing to replace it with:" ,
"Coolify sets the value at runtime, on every deploy, from no declaration at all." ,
] . join ( "\n" ) ;
}
// Every manifest read passes through here (resolve.ts), so a reserved name fails
// the run BEFORE any write — never at the wire, and never half-applied.
export function assertNoReservedEnvNames ( hits : ReservedHit [ ] ) : void {
if ( hits . length === 0 ) return ;
throw new Error ( renderReservedRefusal ( hits ) ) ;
}