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 { assertNoReservedEnvNames, isReservedEnvName } from "./reserved.js";
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
import type { RequiredSecret } from "./resolve.js";
|
|
|
|
|
|
|
|
|
|
// What the manifest writes for a provider-generated name. Not the source box's
|
|
|
|
|
// value — that points at the SOURCE box's Postgres/Redis — and not an empty
|
|
|
|
|
// string, which would boot the app misconfigured. A literal that is obviously
|
|
|
|
|
// a placeholder, and that Coolify replaces when it creates the resource.
|
|
|
|
|
export const GENERATED_PLACEHOLDER = "pending-coolify-generated";
|
|
|
|
|
|
|
|
|
|
// Live env vars, per resource: resource name -> (env key -> value).
|
|
|
|
|
export type LiveEnvs = Record<string, Record<string, string>>;
|
|
|
|
|
|
|
|
|
|
export type Provenance = "captured" | "generated" | "overridden";
|
|
|
|
|
|
|
|
|
|
export type Site = { resource: string; key: string };
|
|
|
|
|
|
|
|
|
|
export type Disposition = {
|
|
|
|
|
ref: string;
|
|
|
|
|
provenance: Provenance;
|
|
|
|
|
// Never rendered. Kept here so the caller can encrypt it, and nowhere else.
|
|
|
|
|
value: string;
|
|
|
|
|
sites: Site[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type Classification = {
|
|
|
|
|
plan: Disposition[];
|
|
|
|
|
// Required by a template, absent from the source, and not dispositioned
|
|
|
|
|
// otherwise. Refuses the run: writing an empty value substitutes to nothing
|
|
|
|
|
// and the app boots misconfigured — the exact failure capture exists to
|
|
|
|
|
// remove, and one that looks entirely plausible from the outside.
|
|
|
|
|
missing: Array<{ ref: string; sites: Site[] }>;
|
|
|
|
|
// The same ref carrying DIFFERENT live values on two resources. cast cannot
|
|
|
|
|
// pick, and picking wrong is silent, so it refuses.
|
|
|
|
|
conflicts: Array<{ ref: string; values: Site[] }>;
|
|
|
|
|
};
|
|
|
|
|
|
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
// The manifest resources that no resource of that name exists for on the source.
|
|
|
|
|
//
|
|
|
|
|
// This is the absent-target lie (#12/D-237) one level deeper in the tree, and it
|
|
|
|
|
// fails in exactly the same way: a resource that is ABSENT reads back
|
|
|
|
|
// identically to one that is PRESENT with no env vars set. Every name it
|
|
|
|
|
// declares reports MISSING, the run refuses, and the message tells the operator
|
|
|
|
|
// to supply each of them with --override — which would "work", writing a
|
|
|
|
|
// perfectly valid store, while the real finding (the manifest and the box
|
|
|
|
|
// disagree about what this resource is called) is never discovered and the
|
|
|
|
|
// operator hand-carries values that were sitting right there under another name.
|
|
|
|
|
//
|
|
|
|
|
// So: refuse on the RESOURCE first, and only report per-name MISSING for
|
|
|
|
|
// resources that were actually found — where it means what it says.
|
|
|
|
|
export function absentResources(
|
|
|
|
|
required: RequiredSecret[],
|
|
|
|
|
liveNames: Iterable<string>,
|
|
|
|
|
): string[] {
|
|
|
|
|
const live = new Set(liveNames);
|
|
|
|
|
const declared = new Set(required.map((r) => r.resource));
|
|
|
|
|
return [...declared].filter((name) => !live.has(name)).sort();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderAbsentResources(
|
|
|
|
|
absent: string[],
|
|
|
|
|
live: Array<{ kind: string; name: string }>,
|
|
|
|
|
ctx: { project: string; environment: string },
|
|
|
|
|
): string {
|
|
|
|
|
const width = Math.max(0, ...live.map((l) => l.kind.length));
|
|
|
|
|
return [
|
|
|
|
|
`refusing to capture: the manifest declares ${absent.length} resource(s) that do not exist here`,
|
|
|
|
|
"",
|
|
|
|
|
` looked in: project "${ctx.project}", environment "${ctx.environment}"`,
|
|
|
|
|
` looked for: ${absent.join(", ")}`,
|
|
|
|
|
" exists here:",
|
|
|
|
|
...(live.length > 0
|
|
|
|
|
? live.map((l) => ` ${l.kind.padEnd(width)} ${l.name}`)
|
|
|
|
|
: [" (nothing at all)"]),
|
|
|
|
|
"",
|
|
|
|
|
"A resource that is absent reads back exactly like one with no env vars set:",
|
|
|
|
|
"every name it declares reports MISSING, and --override would then have you",
|
|
|
|
|
"hand-carry values that are sitting right there under a different name. The",
|
|
|
|
|
"finding is not that the secrets are missing — it is that the manifest and this",
|
|
|
|
|
"box disagree about what these resources are called.",
|
|
|
|
|
"",
|
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
|
|
|
"`cast inventory` shows both sides. Then map them at the call site:",
|
|
|
|
|
"",
|
|
|
|
|
...absent.map(
|
|
|
|
|
(name) => ` --resource ${name}="<what this box calls it>"`,
|
|
|
|
|
),
|
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
].join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
function groupByRef(required: RequiredSecret[]): Map<string, Site[]> {
|
|
|
|
|
const byRef = new Map<string, Site[]>();
|
|
|
|
|
for (const { ref, resource, key } of required) {
|
|
|
|
|
const sites = byRef.get(ref) ?? [];
|
|
|
|
|
sites.push({ resource, key });
|
|
|
|
|
byRef.set(ref, sites);
|
|
|
|
|
}
|
|
|
|
|
return byRef;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Force disposition, never guess. Every name the manifest requires lands in
|
|
|
|
|
// exactly one of four buckets, and two of them stop the run.
|
|
|
|
|
//
|
|
|
|
|
// The mapping is deliberately NOT a mechanical dump of the source box: some
|
|
|
|
|
// entries encode migration decisions rather than facts about the source. A
|
|
|
|
|
// "capture everything" verb would be wrong in a handful of entries out of
|
|
|
|
|
// seventeen, silently — which is worse than being wrong in all of them.
|
|
|
|
|
export function classify(
|
|
|
|
|
required: RequiredSecret[],
|
|
|
|
|
generated: string[],
|
|
|
|
|
live: LiveEnvs,
|
|
|
|
|
overrides: Record<string, string>,
|
|
|
|
|
): Classification {
|
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
|
|
|
// The reserved-name rule, once more at the file (reserved.ts). Unreachable
|
|
|
|
|
// through the CLI — `requiredSecrets` refuses a manifest declaring one before
|
|
|
|
|
// capture is ever called — and kept anyway: it is the invariant, not the check
|
|
|
|
|
// that happens to enforce it today, and the next caller of classify will not
|
|
|
|
|
// have read resolve.ts. Captured, a SOURCE_COMMIT would put the source box's
|
|
|
|
|
// (usually empty) value into the age store, under a name a template already
|
|
|
|
|
// refers to — the trap laundered through the one artifact nobody can read.
|
|
|
|
|
assertNoReservedEnvNames(
|
|
|
|
|
required
|
|
|
|
|
.filter((r) => isReservedEnvName(r.key))
|
|
|
|
|
.map(({ resource, key }) => ({ resource, key })),
|
|
|
|
|
);
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
const generatedSet = new Set(generated);
|
|
|
|
|
const plan: Disposition[] = [];
|
|
|
|
|
const missing: Classification["missing"] = [];
|
|
|
|
|
const conflicts: Classification["conflicts"] = [];
|
|
|
|
|
|
|
|
|
|
for (const [ref, sites] of groupByRef(required)) {
|
|
|
|
|
// The operator's word beats both the manifest and the source box: this is
|
|
|
|
|
// the escape hatch for a value that must NOT be carried over (staging's
|
|
|
|
|
// ADMIN_EMAIL, where the source's value is a real founder and staging
|
|
|
|
|
// shares a Mailgun domain with prod).
|
|
|
|
|
if (ref in overrides) {
|
|
|
|
|
plan.push({
|
|
|
|
|
ref,
|
|
|
|
|
provenance: "overridden",
|
|
|
|
|
value: overrides[ref],
|
|
|
|
|
sites,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (generatedSet.has(ref)) {
|
|
|
|
|
plan.push({
|
|
|
|
|
ref,
|
|
|
|
|
provenance: "generated",
|
|
|
|
|
value: GENERATED_PLACEHOLDER,
|
|
|
|
|
sites,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Captured: read the live value off whichever resources declare it.
|
|
|
|
|
const found = sites
|
|
|
|
|
.map((s) => ({ site: s, value: live[s.resource]?.[s.key] }))
|
|
|
|
|
.filter((f): f is { site: Site; value: string } => f.value !== undefined);
|
|
|
|
|
if (found.length === 0) {
|
|
|
|
|
missing.push({ ref, sites });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const distinct = new Set(found.map((f) => f.value));
|
|
|
|
|
if (distinct.size > 1) {
|
|
|
|
|
conflicts.push({ ref, values: found.map((f) => f.site) });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
plan.push({
|
|
|
|
|
ref,
|
|
|
|
|
provenance: "captured",
|
|
|
|
|
value: found[0].value,
|
|
|
|
|
sites,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { plan, missing, conflicts };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const site = (s: Site) => `${s.resource}.${s.key}`;
|
|
|
|
|
|
|
|
|
|
// Names and provenance. NEVER values.
|
|
|
|
|
//
|
|
|
|
|
// The one thing printed that looks like a value is GENERATED_PLACEHOLDER,
|
|
|
|
|
// which is a literal constant in this file and carries no information about
|
|
|
|
|
// the source box. Everything else is a name the manifest already declares in
|
|
|
|
|
// plaintext, in a committed file.
|
|
|
|
|
export function renderCapturePlan(
|
|
|
|
|
c: Classification,
|
|
|
|
|
ctx: {
|
|
|
|
|
orgRepo: string;
|
|
|
|
|
env: string;
|
|
|
|
|
instance: string;
|
|
|
|
|
store: string;
|
|
|
|
|
recipient: string;
|
|
|
|
|
},
|
|
|
|
|
): string {
|
|
|
|
|
const lines = [
|
|
|
|
|
`capture plan — ${ctx.orgRepo} ${ctx.env}`,
|
|
|
|
|
"",
|
|
|
|
|
` source: instance ${ctx.instance} (live values read from it)`,
|
|
|
|
|
` store: ${ctx.store}`,
|
|
|
|
|
` recipient: ${ctx.recipient}`,
|
|
|
|
|
"",
|
|
|
|
|
];
|
|
|
|
|
const width = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
...[...c.plan, ...c.missing, ...c.conflicts].map((d) => d.ref.length),
|
|
|
|
|
);
|
|
|
|
|
for (const d of c.plan) {
|
|
|
|
|
const where = d.sites.map(site).join(", ");
|
|
|
|
|
const note =
|
|
|
|
|
d.provenance === "generated"
|
|
|
|
|
? ` → ${GENERATED_PLACEHOLDER}`
|
|
|
|
|
: d.provenance === "overridden"
|
|
|
|
|
? ` (from CAST_CAPTURE_${d.ref})`
|
|
|
|
|
: "";
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${d.ref.padEnd(width)} ${d.provenance.padEnd(10)} ${where}${note}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for (const m of c.missing) {
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${m.ref.padEnd(width)} MISSING required by ${m.sites.map(site).join(", ")}, absent from the source`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for (const c2 of c.conflicts) {
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${c2.ref.padEnd(width)} CONFLICT differs between ${c2.values.map(site).join(" and ")}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const counts = (["captured", "generated", "overridden"] as const)
|
|
|
|
|
.map((p) => [p, c.plan.filter((d) => d.provenance === p).length] as const)
|
|
|
|
|
.filter(([, n]) => n > 0)
|
|
|
|
|
.map(([p, n]) => `${n} ${p}`)
|
|
|
|
|
.join(", ");
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`${c.plan.length} name(s) to write${counts ? `: ${counts}` : ""}`,
|
|
|
|
|
);
|
|
|
|
|
if (c.missing.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${c.missing.length} name(s) the manifest requires are not`,
|
|
|
|
|
"present on the source. An empty value substitutes to nothing and the app boots",
|
|
|
|
|
"misconfigured — plausibly, and silently. Supply each one with --override <NAME>",
|
|
|
|
|
"(its value is read from CAST_CAPTURE_<NAME>, never from argv), or fix the source.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (c.conflicts.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${c.conflicts.length} name(s) carry different values on`,
|
|
|
|
|
"different resources of the source. The store holds one value per name, and cast",
|
|
|
|
|
"will not pick for you. Reconcile them on the source, or pin one with --override.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Pass 2 — `capture --generated-only`
|
|
|
|
|
//
|
|
|
|
|
// Bootstrapping an environment that declares generated_secrets is two-pass BY
|
|
|
|
|
// CONSTRUCTION, and the two passes are not variations of one verb:
|
|
|
|
|
//
|
|
|
|
|
// pass 1 `capture` the store learns every name; the generated
|
|
|
|
|
// ones are placeheld, because their values do
|
|
|
|
|
// not exist yet — nothing has created them.
|
|
|
|
|
// (apply) Coolify creates the database and generates
|
|
|
|
|
// the real URL.
|
|
|
|
|
// pass 2 `capture --generated-only` the store learns THAT value.
|
|
|
|
|
//
|
|
|
|
|
// Between the two, the store's value for a generated name is a placeholder while
|
|
|
|
|
// the live value is real. Everything below exists to close that window without
|
|
|
|
|
// the operator hand-editing decrypted plaintext (which is how it was closed
|
|
|
|
|
// before: decrypt, edit two lines, re-encrypt, against prod, holding the key).
|
|
|
|
|
//
|
|
|
|
|
// The flag INVERTS classify()'s disposition rule and nothing else: the names in
|
|
|
|
|
// generated_secrets are the ones it fills, and every other name is left exactly
|
|
|
|
|
// as the store has it — byte for byte, never re-read from the box. A generated
|
|
|
|
|
// name is the only kind of name whose true value cast can find AFTER the fact,
|
|
|
|
|
// because it is the only kind the provider owns.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// A live database inside the project + environment being filled — the resource
|
|
|
|
|
// that OWNS a generated URL.
|
|
|
|
|
//
|
|
|
|
|
// `url` is `internal_db_url`, and it is read from the DATABASE, never from a
|
|
|
|
|
// consuming application's env. It never appears there: the app's env holds
|
|
|
|
|
// whatever the template resolved to, which at this point in the bootstrap is
|
|
|
|
|
// the placeholder itself. Reading the app back would faithfully capture the
|
|
|
|
|
// lie we are here to correct.
|
|
|
|
|
export type GeneratedSource = {
|
|
|
|
|
resource: string;
|
|
|
|
|
type: string;
|
|
|
|
|
url: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// One generated name, filled from the resource that owns it.
|
|
|
|
|
export type Fill = {
|
|
|
|
|
ref: string;
|
|
|
|
|
from: { resource: string; type: string };
|
|
|
|
|
// Never rendered. Kept here so the caller can encrypt it, and nowhere else —
|
|
|
|
|
// same contract as Disposition.value.
|
|
|
|
|
value: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type GeneratedPlan = {
|
|
|
|
|
fills: Fill[];
|
|
|
|
|
// Names the store keeps, byte for byte. Printed as names so the operator can
|
|
|
|
|
// see the store is not being rewritten around them.
|
|
|
|
|
kept: string[];
|
|
|
|
|
// A generated name cast cannot attribute to exactly one database. Refuses:
|
|
|
|
|
// picking would be silent, and picking WRONG writes another box's credentials
|
|
|
|
|
// into this environment's store.
|
|
|
|
|
unmapped: Array<{ ref: string; why: string }>;
|
|
|
|
|
// A generated name whose store value is NOT the placeholder. Refuses without
|
|
|
|
|
// --force: it is already filled (someone ran pass 2, or set it by hand), and
|
|
|
|
|
// overwriting it is a silent rotation of a live credential.
|
|
|
|
|
occupied: string[];
|
|
|
|
|
// A generated name the store does not carry AT ALL. Refuses: pass 2 fills
|
|
|
|
|
// names, it does not invent them. A store missing one did not come from pass
|
|
|
|
|
// 1, and the name-count postcondition below could not hold anyway.
|
|
|
|
|
absent: string[];
|
|
|
|
|
// A store value that is still the placeholder and is NOT in the fill set —
|
|
|
|
|
// so this run would leave the store still lying. Refuses. This is the
|
|
|
|
|
// postcondition, checked BEFORE the write so it reads as a plan and not as an
|
|
|
|
|
// assertion failure over ciphertext already on disk.
|
|
|
|
|
stillPending: string[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Which database does a generated name come from?
|
|
|
|
|
//
|
|
|
|
|
// The manifest cannot say. `generated_secrets:` is a flat list of NAMES, and
|
|
|
|
|
// there is no edge anywhere in the data model from DATABASE_URL to the database
|
|
|
|
|
// that owns it — not in the manifest, not in the env template (which knows only
|
|
|
|
|
// `DATABASE_URL=${DATABASE_URL}`), and not on the box.
|
|
|
|
|
//
|
|
|
|
|
// So cast does not guess. It infers ONLY when the inference cannot be wrong —
|
|
|
|
|
// one generated name, one database, no other candidate — and otherwise refuses
|
|
|
|
|
// and makes the operator state the edge with --from. The temptation is to read
|
|
|
|
|
// the type out of the NAME (REDIS_URL → the redis one), and that is precisely
|
|
|
|
|
// the bug this verb must not have: a name-directed pick across a list of
|
|
|
|
|
// databases is #29 wearing a different hat, and it is wrong SILENTLY. The
|
|
|
|
|
// value it would write is a perfectly well-formed URL to somebody else's
|
|
|
|
|
// database.
|
|
|
|
|
export function resolveGeneratedSources(
|
|
|
|
|
generated: string[],
|
|
|
|
|
databases: GeneratedSource[],
|
|
|
|
|
from: Record<string, string>,
|
|
|
|
|
): {
|
|
|
|
|
mapping: Record<string, GeneratedSource>;
|
|
|
|
|
unmapped: Array<{ ref: string; why: string }>;
|
|
|
|
|
} {
|
|
|
|
|
const mapping: Record<string, GeneratedSource> = {};
|
|
|
|
|
const unmapped: Array<{ ref: string; why: string }> = [];
|
|
|
|
|
const byName = new Map(databases.map((d) => [d.resource, d]));
|
|
|
|
|
for (const ref of generated) {
|
|
|
|
|
const named = from[ref];
|
|
|
|
|
if (named !== undefined) {
|
|
|
|
|
const hit = byName.get(named);
|
|
|
|
|
if (!hit) {
|
|
|
|
|
unmapped.push({
|
|
|
|
|
ref,
|
|
|
|
|
why: `--from ${ref}=${named}, but no database named "${named}" exists in this project+environment`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
mapping[ref] = hit;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (databases.length === 0) {
|
|
|
|
|
unmapped.push({
|
|
|
|
|
ref,
|
|
|
|
|
why: "no database exists in this project+environment to fill it from",
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// The only inference that cannot be wrong: nothing else it could be.
|
|
|
|
|
if (generated.length === 1 && databases.length === 1) {
|
|
|
|
|
mapping[ref] = databases[0];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
unmapped.push({
|
|
|
|
|
ref,
|
|
|
|
|
why: `${databases.length} databases here (${databases.map((d) => `${d.resource}:${d.type}`).join(", ")}) — cast will not pick by name`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { mapping, unmapped };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Invert the disposition rule. `store` is the DECRYPTED current store; every
|
|
|
|
|
// name in it that is not being filled comes out the other side untouched.
|
|
|
|
|
export function planGenerated(
|
|
|
|
|
generated: string[],
|
|
|
|
|
store: Record<string, string>,
|
|
|
|
|
sources: Record<string, GeneratedSource>,
|
|
|
|
|
unmapped: Array<{ ref: string; why: string }>,
|
|
|
|
|
opts: { force: boolean } = { force: false },
|
|
|
|
|
): GeneratedPlan {
|
|
|
|
|
const generatedSet = new Set(generated);
|
|
|
|
|
const fills: Fill[] = [];
|
|
|
|
|
const occupied: string[] = [];
|
|
|
|
|
const absent: string[] = [];
|
|
|
|
|
for (const ref of generated) {
|
|
|
|
|
if (!(ref in store)) {
|
|
|
|
|
absent.push(ref);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const source = sources[ref];
|
|
|
|
|
// Already reported by resolveGeneratedSources; not also an "occupied".
|
|
|
|
|
if (!source) continue;
|
|
|
|
|
if (store[ref] !== GENERATED_PLACEHOLDER && !opts.force) {
|
|
|
|
|
occupied.push(ref);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
fills.push({
|
|
|
|
|
ref,
|
|
|
|
|
from: { resource: source.resource, type: source.type },
|
|
|
|
|
value: source.url,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const filling = new Set(fills.map((f) => f.ref));
|
|
|
|
|
// A placeholder left standing in a name nobody is filling. The run would
|
|
|
|
|
// "succeed" and the store would still be a lie — so it refuses instead, and
|
|
|
|
|
// names the flag that fixes it. Generated names that failed to map or were
|
|
|
|
|
// refused as occupied are NOT reported here: they already have their own row,
|
|
|
|
|
// and one problem should be named once.
|
|
|
|
|
const stillPending = Object.keys(store)
|
|
|
|
|
.filter(
|
|
|
|
|
(k) =>
|
|
|
|
|
store[k] === GENERATED_PLACEHOLDER &&
|
|
|
|
|
!filling.has(k) &&
|
|
|
|
|
!generatedSet.has(k),
|
|
|
|
|
)
|
|
|
|
|
.sort();
|
|
|
|
|
// Everything the store keeps byte for byte. A name carrying a refusal above
|
|
|
|
|
// is not "kept" — it is the reason the run stops, and printing it in both
|
|
|
|
|
// rows would read as though cast had a plan for it.
|
|
|
|
|
const flagged = new Set([
|
|
|
|
|
...unmapped.map((u) => u.ref),
|
|
|
|
|
...occupied,
|
|
|
|
|
...stillPending,
|
|
|
|
|
]);
|
|
|
|
|
const kept = Object.keys(store)
|
|
|
|
|
.filter((k) => !filling.has(k) && !flagged.has(k))
|
|
|
|
|
.sort();
|
|
|
|
|
return { fills, kept, unmapped, occupied, absent, stillPending };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function generatedPlanRefuses(p: GeneratedPlan): boolean {
|
|
|
|
|
return (
|
|
|
|
|
p.unmapped.length > 0 ||
|
|
|
|
|
p.occupied.length > 0 ||
|
|
|
|
|
p.absent.length > 0 ||
|
|
|
|
|
p.stillPending.length > 0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Names, provenance and the resource a value came FROM. Never values.
|
|
|
|
|
//
|
|
|
|
|
// capture.ts's rule holds unchanged: the only value-shaped thing printed is
|
|
|
|
|
// GENERATED_PLACEHOLDER, a literal constant in this file — and here it is
|
|
|
|
|
// printed as the thing being REPLACED, which is the one fact about the store's
|
|
|
|
|
// current contents an operator needs in order to believe the plan.
|
|
|
|
|
export function renderGeneratedPlan(
|
|
|
|
|
p: GeneratedPlan,
|
|
|
|
|
ctx: {
|
|
|
|
|
orgRepo: string;
|
|
|
|
|
env: string;
|
|
|
|
|
instance: string;
|
|
|
|
|
store: string;
|
|
|
|
|
recipient: string;
|
|
|
|
|
project: string;
|
|
|
|
|
environment: string;
|
|
|
|
|
},
|
|
|
|
|
): string {
|
|
|
|
|
const lines = [
|
|
|
|
|
`capture --generated-only — ${ctx.orgRepo} ${ctx.env}`,
|
|
|
|
|
"",
|
|
|
|
|
` source: instance ${ctx.instance}, project "${ctx.project}", environment "${ctx.environment}"`,
|
|
|
|
|
` store: ${ctx.store}`,
|
|
|
|
|
` recipient: ${ctx.recipient}`,
|
|
|
|
|
"",
|
|
|
|
|
];
|
|
|
|
|
const width = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
...[
|
|
|
|
|
...p.fills.map((f) => f.ref),
|
|
|
|
|
...p.kept,
|
|
|
|
|
...p.occupied,
|
|
|
|
|
...p.absent,
|
|
|
|
|
...p.unmapped.map((u) => u.ref),
|
|
|
|
|
].map((r) => r.length),
|
|
|
|
|
);
|
|
|
|
|
for (const f of p.fills) {
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${f.ref.padEnd(width)} fill ${GENERATED_PLACEHOLDER} → ${f.from.resource} (${f.from.type}) internal_db_url`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for (const k of p.kept) {
|
|
|
|
|
lines.push(` ${k.padEnd(width)} keep unchanged in the store`);
|
|
|
|
|
}
|
|
|
|
|
for (const u of p.unmapped) {
|
|
|
|
|
lines.push(` ${u.ref.padEnd(width)} UNMAPPED ${u.why}`);
|
|
|
|
|
}
|
|
|
|
|
for (const o of p.occupied) {
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${o.padEnd(width)} OCCUPIED already holds a value that is not ${GENERATED_PLACEHOLDER}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for (const a of p.absent) {
|
|
|
|
|
lines.push(` ${a.padEnd(width)} ABSENT not in the store at all`);
|
|
|
|
|
}
|
|
|
|
|
for (const s of p.stillPending) {
|
|
|
|
|
lines.push(
|
|
|
|
|
` ${s.padEnd(width)} PENDING still ${GENERATED_PLACEHOLDER}, and nothing here fills it`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`${p.fills.length} name(s) to fill, ${p.kept.length} left exactly as the store has them`,
|
|
|
|
|
);
|
|
|
|
|
if (p.unmapped.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${p.unmapped.length} generated name(s) cannot be attributed`,
|
|
|
|
|
"to exactly one database in this project+environment. Nothing in the manifest, the env",
|
|
|
|
|
"template or the box says which database a given name comes from, and cast will not",
|
|
|
|
|
"pick by name — a wrong pick writes another database's credentials into this store,",
|
|
|
|
|
"and it does it silently. State the edge:",
|
|
|
|
|
"",
|
|
|
|
|
...p.unmapped.map((u) => ` --from ${u.ref}=<database name>`),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (p.occupied.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${p.occupied.length} generated name(s) already hold a real`,
|
|
|
|
|
"value. Filling them would rotate a live credential — silently, and against whatever is",
|
|
|
|
|
"already running on that value. If Coolify's resource really was recreated and the store",
|
|
|
|
|
"is stale, say so with --force.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (p.absent.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${p.absent.length} generated name(s) are not in the store at`,
|
|
|
|
|
"all. --generated-only FILLS names, it does not add them: the store it fills is the one",
|
|
|
|
|
"pass 1 wrote, and the name set must come out of this run exactly as it went in. Run",
|
|
|
|
|
"`cast capture` first, or check you are pointed at the right store.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (p.stillPending.length > 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
`refusing to write the store: ${p.stillPending.length} name(s) would still hold the`,
|
|
|
|
|
`${GENERATED_PLACEHOLDER} literal after this run — the store would still be a lie, and`,
|
|
|
|
|
"the next apply would push that literal at a live app. If these are generated too,",
|
|
|
|
|
"declare them (manifest `generated_secrets:`, or --generated <NAME>).",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return lines.join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The postcondition this verb EXISTS for, asserted against the store that was
|
|
|
|
|
// actually written — decrypted back off disk, not against the map cast held in
|
|
|
|
|
// memory a moment ago. In the hand-run procedure this was a line in a runbook
|
|
|
|
|
// ("assert 14 names / zero placeholders"), which is to say it was a step that
|
|
|
|
|
// could be, and eventually would be, skipped.
|
|
|
|
|
//
|
|
|
|
|
// Two claims, and they fail in opposite directions:
|
|
|
|
|
// - zero placeholders remain → the store no longer lies about any name
|
|
|
|
|
// - the name count is unchanged → and it did not lose one on the way
|
|
|
|
|
//
|
|
|
|
|
// A store that lost a name re-encrypts perfectly and reads back perfectly; the
|
|
|
|
|
// failure surfaces at the next apply, as a missing secret, in an environment
|
|
|
|
|
// whose plaintext nobody has any more.
|
|
|
|
|
export function assertGeneratedComplete(
|
|
|
|
|
before: Record<string, string>,
|
|
|
|
|
after: Record<string, string>,
|
|
|
|
|
): string[] {
|
|
|
|
|
const violations: string[] = [];
|
|
|
|
|
const pending = Object.keys(after)
|
|
|
|
|
.filter((k) => after[k] === GENERATED_PLACEHOLDER)
|
|
|
|
|
.sort();
|
|
|
|
|
if (pending.length > 0) {
|
|
|
|
|
violations.push(
|
|
|
|
|
`${pending.length} name(s) still hold the ${GENERATED_PLACEHOLDER} literal: ${pending.join(", ")}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const beforeNames = Object.keys(before).sort();
|
|
|
|
|
const afterNames = Object.keys(after).sort();
|
|
|
|
|
if (beforeNames.length !== afterNames.length) {
|
|
|
|
|
violations.push(
|
|
|
|
|
`the store went in with ${beforeNames.length} name(s) and came out with ${afterNames.length}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const lost = beforeNames.filter((n) => !(n in after));
|
|
|
|
|
const gained = afterNames.filter((n) => !(n in before));
|
|
|
|
|
if (lost.length > 0) violations.push(`names LOST: ${lost.join(", ")}`);
|
|
|
|
|
if (gained.length > 0) violations.push(`names ADDED: ${gained.join(", ")}`);
|
|
|
|
|
return violations;
|
|
|
|
|
}
|