fix(apply): refuse to write the generated-secret placeholder over a live value (#47) #55
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#55
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/generated-secret-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The problem
The bootstrap is two-pass, and only the first pass was ever safe to repeat.
A manifest declares
generated_secrets: [DATABASE_URL_PROD, REDIS_URL_PROD], so the store holds them as the literalpending-coolify-generated. The firstapplysends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong, and nothing knew it:GENERATED_PLACEHOLDERwas referenced only bycapture.tsanddraft.ts— the write-side-of-the-store verbs.diffandapply, the only two that could act on it, had never heard of the one literal cast invented to mean "this value is not real yet".So
diffprintedsecret DATABASE_URL differs— word for word what a legitimate rotation of the same secret prints — andapplystood ready to PATCH the placeholder back over the live, working URL and redeploymigrate,apiandworkeronto it.Nothing on the far side stopped it either. Confirmed against the real source rather than the (provably incomplete) vendored OpenAPI spec —
coollabsio/coolify@v4.1.2,app/Http/Controllers/Api/ApplicationsController.php::create_bulk_envs: for a key that already exists, the handler does$env->value = $item->get('value'); … $env->save();. A plain overwrite.cli.ts:syncEnvbulk-upserts every var from the store verbatim, so the placeholder lands.heavy-duty/incubator's prod box is in exactly this state today, held off by a hand-written "run NOcast applyagainst prod" line in an operator punch list.What changed
diff.ts—diffEnvgives the placeholder its ownEnvDiffstate,placeholder-conflict, when the store holds the placeholder for a secret var and the live resource holds anything else. Live value also-placeholder → no diff at all. Var absent live → plainadd. Create path untouched — the placeholder is correct there (Coolify replaces it), anddiffEnvonly ever runs against a live resource, so a create can't reach the new state by construction.diff.ts—renderDiffsays it in words no rotation prints:secret DATABASE_URL: store holds the generated-secret PLACEHOLDER, live holds a real value — apply would OVERWRITE it, plus a count in the summary line (the line an operator actually reads before typingapply).apply.ts—applyPlanREFUSES, it does not warn. Before any resource is touched, same fail-closed shape as the not-updatable refusal above it. The message names the key and the resource — never the live value (capture.ts's rule) — and points at the remedy.placeholderConflicts(report)is the one reading of the report that both the warning and the refusal use, so they cannot disagree about what counts as one.docs/semantics.mddocuments the two-pass bootstrap and the full store×live disposition table.Where the issue was wrong, and what I did instead
The issue prescribes that
computeDifflearn the environment'sgenerated_secretslist and match env vars against it. That check would have sailed straight past the case that motivated the issue.generated_secrets:names store refs; anEnvDiffis keyed by env var key; the template maps one to the other and they routinely differ. The repo's own fixtures prove it —test/capture.test.tsdeclaresgenerated_secrets: [DATABASE_URL_PROD]over a template lineDATABASE_URL=${DATABASE_URL_PROD}, andrequiredSecretsreturns{ ref: "DATABASE_URL_PROD", key: "DATABASE_URL" }. Matching the declared names against env-var keys finds nothing. Threading a ref→key map through instead would mean wideningResolvedEnv(envtemplate.ts), outside this PR's blast radius and a rebase conflict for the siblings.So the guard is keyed on the store's value, which carries the same fact to where it is needed:
resolveTemplatecopies the store's value in verbatim, andsecretis true exactly when the RHS was a single${REF}.v.secret && v.value === GENERATED_PLACEHOLDERis "this var's store ref currently holds the placeholder".It is also the stricter rule, and deliberately so. A name dropped from
generated_secrets:while the store still holds the placeholder — the first half of the remedy the issue itself suggests — would pass the declaration-keyed check and writepending-coolify-generatedover a live database URL. The placeholder is a promise, never a value; writing it over anything real is never right, whatever the manifest currently says. The refusal therefore tells the operator to drop the name and capture its real value, not just the former.Net effect: no
cli.tschange was needed at all. The diff isdiff.ts+apply.ts+ tests + docs, which should also make the rebase against #45/#50/#51 mechanical.Tests
312 passing, up from 294 — 18 new, and the refusal path is tested hard, since it is the entire product of this PR.
test/diff.test.ts(8): conflict is raised, and is not achange; live-also-placeholder is clean; absent-live is a plainadd; the create path is a plainadd; an ordinary rotation stays a plainchange; a non-secret template literal that happens to readpending-coolify-generatedis not flagged; a structural diff raises nothing;renderDiffprints the loud line, drops the oldsecret X differsline, counts it in the summary, and leaks neither the URL nor its password.test/apply.test.ts(10, as a nesteddescribe): refuses before any mutation (recorder sees zero calls — nosyncEnv, noredeploy); names every conflicted key and its resource; never prints the live value or its password; points atcast capture --generated-onlyand #48; refuses even when the conflict rides along with legitimate updatable drift (the refusal is not a filter — nothing goes out); refuses on a conflict carried by a second resource behind a first one that carries non-updatable drift, so the data-loss refusal is the one an operator is told about first; still sends the placeholder on a create; proceeds when live is also the placeholder; proceeds when the var is absent live; and still applies an ordinary secret rotation — a guard that turns every rotation into a refusal is a guard that gets disabled.Also verified end-to-end against the compiled
dist/, not just the unit imports: the refusal fires andsyncEnvis never reached.Noticed, not fixed (out of blast radius)
applyPlan'sneedsEnvsends the entire resolved env on any env drift, so one changed var rewrites all of them. Harmless today (upsert of identical values), but it means the blast radius of any env write is always the whole resource. Adjacent to #45's loop work.Closes #47.
🤖 Generated with Claude Code