cast diff re-proposes the five prod flags as change — root cause UNKNOWN (real_value-stale theory disproved)
#78
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#78
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
cast diffreports a phantomenv … : changefor an env var that was updated in place and is genuinely correct on the box. The live value the operator sees (Coolify UI) and the value the container actually runs with are both right; only cast's diff disagrees — so a re-apply"changes" it again, and every future diff re-proposes it. A false-drift that never clears also masks real drift.Reproduction (prod flag flip, 2026-07-16)
REPORTING_ENABLED,BRAIN_ENABLED,BRANDED_EMAIL_ENABLED,EMAIL_PREVIEW_ENABLED,OPERATOR_SETTINGS_ENABLED).cast apply --env prodsets them and redeployscore. Verified live: Coolify UI showsvalue=truefor all five; the running container booted with them on (admin nav renders the flag-gated pages;/version200 ⇒loadConfigdidn't throw).cast diff --env prod --fullimmediately after still lists all five asenv … : change.NODE_ENV,REPORTING_TZ, the derived base-URLs) do not re-propose — only the five that were flipped in place.Root cause
fetchEnv(src/cli.ts) builds the live map preferringreal_value:diffEnv(src/diff.ts) then compareslive[key] !== v.valueagainst the manifest literal"true".Coolify's stored
real_valuegoes stale after an in-place PATCH ofvalue: the update setsvalue="true"but leavesreal_value="false"(a redeploy does not refresh it — the drift persists after the apply's redeploy). cast reads the stalereal_value→"false" !== "true"→ phantomchange. Created-once vars have a freshreal_value, so they match — which is exactly why only the flipped-in-place vars re-propose.Confirming evidence
GET /api/v1/applications/{core-uuid}/envsfor the five flags showsvalue:"true"alongsidereal_value:"false". (operator to attach the exact JSON)Impact
applyre-writes a value that is already correct.real_valuestale → a phantomsecret X differs.Proposed fix
Not a blanket "read
value" — thereal_value ?? valuechoice is deliberate: for secrets,valueis masked andreal_valueis the plaintext (needsread:sensitive), so cast must keepreal_valuethere. The stale-real_valuehazard is specific to non-secret in-place updates.Two directions:
value(always fresh), keepreal_valuefor secrets. cast already knowsv.secreton the desired side (ResolvedEnv), so the choice can be made per-var.fetchEnvcurrently discards the{value, real_value}split before the diff sees it — either carry both through todiffEnv, or havediffEnvpick perv.secret.syncEnv's update force Coolify to recomputereal_value(it's a stored column the API PATCH doesn't refresh; a redeploy doesn't either).Repro-test outline
diffEnv/fetchEnv: live var{key:"REPORTING_ENABLED", value:"true", real_value:"false"}, non-secret, desired"true"→ no diff.valueis masked andreal_valueis the plaintext must still compare viareal_value(don't break the secret path).Context
heavy-duty/incubatorD-280); prod is correct, this is a diff-fidelity bug.is_staticcast#68;destination/backup"declared, NOT compared"). This one currently produces false drift rather than a "not compared" line.real_value) → next diff clean.Reopening: the symptom persists, and this issue's stated root cause is wrong.
cast diff --env prodrun today against prod, on a cast that provably contains #79 (the same install diffsservice_domains, which only exists post-#81):Still all five. #79 did not fix this.
The root cause above is false
real_valueis not a stored column. It is anAttributeaccessor, declared in$appendsand recomputed fromvalueon every read (app/Models/EnvironmentVariable.php:81,171-207@ v4.1.2):It therefore cannot go stale. What it actually is: a shell-escaped / quoted rendering of
value— single-quoted whenis_literal/is_multiline, otherwiseescapeEnvVariables(...). Andvalueitself istrim(decrypt(...))(:150-156), the raw decrypted value.The "confirming evidence" this issue rested on (
value:"true"alongsidereal_value:"false") was never actually captured — the body says "(operator to attach the exact JSON)". Nothing here was ever verified against the wire.What that means for #79
#79 still fixes a real defect, and I'd keep it: comparing a manifest literal against an escaped rendering is wrong on its face, and would produce phantom drift for any value whose escaping differs from its raw form (
is_literal⇒'true'≠true; anythingescapeEnvVariablestouches). It just isn't what is biting these five.Where the trail now leads
The five are plain literals in the env template (
REPORTING_ENABLED=true, not${REF}), sosecret: false, so post-#79diffEnvcompares them againstlive.value. They still differ ⇒live.valueis not"true". This is not token-masking either: every other manifest var on the same resource (NODE_ENV,REPORTING_TZ, the derived base-URLs) compares equal and never appears in the diff.Which leaves the uncomfortable possibility the original diagnosis foreclosed: the stored value on the box may genuinely not be
true, whatever the UI renders — i.e. the diff may have been correct all along, and "diagnosed benign" may have been the actual defect.Next step — the evidence this issue never got
{key, value, real_value, is_literal}for one flag settles it. Blocking anycast apply --env produntil then: if the box really isfalse, this stops being a diff-fidelity bug and becomes the five prod flags are not actually on.Root cause found: #85. Not
real_value— a duplicate row.The probe came back with two rows per key:
GET /applications/{uuid}/envsmerges two disjoint sets into one flat array (ApplicationsController@envsmergesenvironment_variableswithenvironment_variables_preview), so a key can legitimately arrive twice. cast'sfetchEnvcollapses that withObject.fromEntries— last wins — so it diffs the manifest's"true"against the trailing"false"row. Full analysis and fix in #85.Correcting this issue's record, point by point
real_valuegoes stale after an in-place PATCH" — false twice over.real_valueis anAttributeaccessor recomputed fromvalueon every read, so it cannot go stale; and the probe showsreal_value == valueon every row, exactly as the accessor predicts (is_literal: false⇒escapeEnvVariables("true") == "true"). The evidence line that would have caught this — "(operator to attach the exact JSON)" — was never filled in, and the theory stood for it.applyPATCHes only the production row, leaving its twin behind. So they diverge for precisely the in-place-updated vars.NODE_ENV/REPORTING_TZ/the${domain:...}base-URLs never diverged, so last-wins happens to pick a matching value and they read clean through the identical path. The "something goes stale after an in-place PATCH" intuition was sound — the stale thing is a second row, not a computed field."true". The flags really are on; the diff was the thing that was wrong. "Diagnosed benign" reached the right conclusion by the wrong route.On #79
Keep it. Comparing a manifest literal against an escaped rendering is wrong regardless (
is_literal⇒'true'≠true), so it fixes a real defect and a latent secret-rotation hazard — it just was never what was biting these five. Its stated motivation is now wrong and should be re-pointed at #85.Closing this in favour of #85, which carries the confirmed cause, the probe, and the fix.