apply writes the pending-coolify-generated placeholder over a live generated secret — a routine command takes prod down
#47
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#47
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
What happens
A manifest declares
generated_secrets: [DATABASE_URL, REDIS_URL]. The store therefore holds them as the literalpending-coolify-generated— by design (capture.ts:7,semantics.md:421). The firstapplycreates the Postgres/Redis resources and Coolify generates the real URLs.From that moment the store is wrong, and
applydoes not know it:That line is
applyannouncing it intends to PATCHpending-coolify-generatedover the live, working URL — and then redeploy. Onheavy-duty/incubator's prod box, which is up and migrated right now, the next routinecast applytakes the database connection away frommigrate,apiandworker. Not a risk: a certainty, from the tool's happy path. It is currently held off by a hand-written "run NOcast applyagainst prod" line in an operator punch list — which is not a guardrail, it is a post-it note on a loaded gun.Root cause
GENERATED_PLACEHOLDERis exported fromcapture.ts:7and is referenced only bycapture.tsanddraft.ts— the two write-side-of-the-store verbs.diffandapplyhave never heard of it:diff.ts:diffEnvcompareslive[key] !== v.valueand emits a plainchange— the placeholder is just a string that differs.apply.ts:100→syncEnv→cli.ts:2129bulk-UPSERTs every var from the store verbatim.So the one literal cast itself invented to mean "this value is not real yet" is invisible to the only two verbs that could act on it.
Why it went unnoticed
generated_secretswere designed for the first apply, where writing the placeholder is harmless: Coolify overwrites it when it creates the resource. Nobody had yet run a second apply against a project whose generated secrets had since been filled in — because until this week no project cast created from nothing had ever reached a second apply. The bootstrap is two-pass and only the first pass had ever run.Suggested
applymust refuse, not warn — this is a data-loss write, and the plan line that precedes it is indistinguishable from a legitimate secret rotation:Precisely:
generated_secrets, its store value is the placeholder literal, and the live value is anything else → refuse the apply. Live value also-placeholder, or var absent live → not a conflict, proceed.Two adjacent points, both worth taking in the same change:
diffshould say it too, and say it loudly — todaysecret DATABASE_URL differsis the only signal, and it is the same words a real rotation prints. It should readsecret DATABASE_URL: store holds the generated-secret PLACEHOLDER, live holds a real value — apply would OVERWRITE it.capture's disposition table (capture.ts:166).Related
captureplaceholds agenerated_secretby design. Filed separately as #48; this issue is the guard, that one is the fix that makes the guard actionable.OPERATOR.md, top row).#60 would make this failure structurally impossible for the two names currently causing it: if
DATABASE_URL/REDIS_URLare derived from the declared database rather than stored, there is no placeholder in the store to overwrite the live value with, and thesecret DATABASE_URL differsline stops being printed because the store no longer holds a competing copy.Worth keeping this guard regardless, as a backstop for any residual provider-generated secret that is genuinely not derivable — but the live instance of it (the loaded store on
heavy-duty/incubator's prod box) is dissolved by #60, not merely defended against.