fetchEnv silently collapses duplicate env keys — a shadow row makes every in-place-updated var diff forever (cause of #78) #85
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#85
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?
Summary
GET /{resource}/{uuid}/envsdoes not return one row per key. It merges two disjoint sets — the production vars and the preview vars — into a single flat array:Both collections are numerically keyed, so
merge()appends (array_merge semantics on numeric keys) rather than overwriting — the response can legitimately contain two rows with the samekey, oneis_preview: false, oneis_preview: true.cast collapses that with last-wins, and has no notion of
is_previewat all:Object.fromEntrieskeeps the last entry per key. So when both rows exist, cast diffs the manifest against whichever row Coolify happened to serialize last — and says nothing.Why this is a defect on its own terms
cast declares production env. A preview var is a different deployment's value for the same name. Diffing against it produces drift cast can neither explain nor clear, and
applywould then PATCH the production var to match something that was never about production. It fails in the shape this repo keeps legislating against (#12/#14/#17/#18): silently, with a confident-looking report — no line anywhere says "two rows carry this key."It also quietly widens: every caller of
fetchEnvinherits it —capture's store classification (flattenEnv),draft's scaffolding, andinventory's key listing.CONFIRMED — this is the cause of #78
Probed against prod 2026-07-16. Two rows per key, and
real_valuetracksvalueon every one of them:"true"— the flags are genuinely on. Prod was correct all along; the diff was the thing that was wrong.real_value == valueon every row, which is exactly what the accessor predicts (is_literal: false⇒escapeEnvVariables("true") == "true"). #78's "stalereal_value" theory is dead: nothing is stale.Object.fromEntrieskeeps the last row — the"false"one — and diffs it against the manifest's"true". Hence a phantomchangethat can never clear.Why only the in-place-flipped vars re-propose
#78 spotted this and misattributed it. Both rows start equal at creation.
cast applyPATCHes only the production row (false→true), leaving its twin atfalse— so the two diverge for exactly the vars that were updated in place.NODE_ENV,REPORTING_TZand the${domain:...}base-URLs were never flipped, so their rows still agree and last-wins picks a value that happens to match — which is why four other non-secret vars read clean through the identical code path.The "something goes stale after an in-place PATCH" intuition was right. The stale thing is a second row, not a computed field.
Probe (reproduce / check any resource)
Dumps every entry for a key rather than the first, which is the whole point:
count: 2is the bug. The remaining unknown is which flag distinguishes the two rows — includeis_preview, since it decides the fix:is_preview: true⇒ it is a preview-deployment var;fetchEnvmust drop preview rows.is_preview: false⇒ Coolify is holding two production rows for one key, which cast must refuse to silently pick between (thebackupNotComparedprecedent).Whole-resource sweep for any duplicated key:
What to do
fetchEnv: dropis_preview: truerows — cast declares production env; a preview var is another deployment's and is not cast's to compare or writebackupNotComparedprecedent)flattenEnv/ thecapture+draft+inventorycallersis_preview→ the production row is what diffs, whatever the serialization orderFound while investigating #78 (root cause unknown after the
real_valuetheory was disproved).Verified against prod — the fix works.
cast diff --env prod --full, on a cast installed frommain:All five gone. The probe on the same box confirms the shape this issue describes:
{value:"true", is_preview:false}and{value:"false", is_preview:true}per key, withreal_value == valueon every row.One loose end worth confirming before this is fully closed
The same run also dropped two orphan lines that were present immediately before:
That was not predicted, and it should be explained rather than enjoyed.
smoke.tswrites both withis_preview: false(lines 39-60), andcreate_bulk_envshonours the payload'sis_previewwithout duplicating — so on the model above these are production rows, and theis_preview !== truefilter cannot drop them.Three readings, and only one of them is comfortable:
is_preview: trueon this box → the filter is right, and they had been reported as orphans in error all along.is_preview: false→ the filter is dropping production rows, i.e. #86 regressed the read it was meant to fix, and it should be reverted/narrowed.Probe, copy-paste:
Leaving this open until that comes back — a fix whose side effects aren't understood isn't finished, and reading (1) would additionally mean
cast smoke's canary has been sitting in the preview set, which is its own bug.(Related, from the same run: #87 — with this fixed, Coolify's own
SERVICE_*magic vars are all that keep a correct box from reading clean.)