diff/apply never converge on a compose app's docker_compose_domains or a static app's is_static — perpetual drift + redeploy every apply #68
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#68
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 diff(and thereforeapply) reports the same two field changes on every run against a live Coolify 4.1.2 — even immediately after anapplythat wrote them:The writes land correctly — the apex domains route, the static site serves — but cast's live projection reads them back as
undefined/false, socomputeDiffseesdesired vs undefined/falseforever → a spurious PATCH plus acore+landingredeploy on every apply. A no-op re-apply (idempotency) is impossible.Where
fetchLive(src/cli.ts) readsGET /projects/{uuid}/{env}(@environment_details); its embeddedapplications[]feedprojectLiveFields(src/cli.ts~L310):docker_compose_domains→parseDockerComposeDomains(~L288) collapses anything that isn't a JSON-encoded[{name,domain}]string toundefined.is_static(~L342) accepts onlyraw.is_static === true || raw.is_static === 1— not, e.g., a string"1"/"true".So either the
environment_detailsembed doesn't carry these fields, or it carries them in a type/shape the projection rejects.parseDockerComposeDomains's own comment flagged this as "unverified until Task 8 step 6" of the incubator prod migration — this is that verification (heavy-duty/incubatorprod, 2026-07-15, cast @95c9bef, first apply had already set both).Impact
core+landingredeploy needlessly every time (breaks the idempotency guarantee).diff/applyconvergence.Diagnostic probe
Run against the live instance to decide the fix — dumps the raw type + value of both fields from both the route
fetchLiveuses and the flat per-app model:(Adjust
incubator/prodif the Coolify project or environment name differs.)Two outcomes → two fixes
core.docker_compose_domains/landing.is_static)is_static_type: "string"("1"/"true"), ordcdpresent but not the[{name,domain}]JSON cast expects — on either routeis_statictruthiness check +parseDockerComposeDomains(and/or read these from the per-app model if theenvironment_detailsembed is the sparse one)null/falseon both routes even though setconsole.warn-and-drop the field from the compose-app diff (the prod-migration runbook's documented fallback); domain-flip + static become create-time / UI acts, and the migration runbook must say soNotes
environment_details→projectLiveFieldsread-back). They may need distinct handling (is_statictype coercion vsdocker_compose_domainsshape), but the diagnosis and the write side are shared.heavy-duty/incubatorprod migration, Task 8 step 6 (the idempotency /docker_compose_domainsround-trip HARD-STOP gate).Probe result (incubator prod on
coolify-box) — two distinct root causes1.
docker_compose_domains— PARSE BUG (the field IS exposed)Coolify 4.1.2 returns it as a JSON-encoded object keyed by service —
{"<svc>":{"domain":"<comma-joined>"}}— not the JSON array[{name,domain}]thatparseDockerComposeDomainsrequires (that's the write shape). So!Array.isArray(parsed)→undefined→ perpetualundefined → {…}diff + redeploy. Fix: parse the object shape ({svc:{domain:"a,b"}}→{svc:["a","b"]}), keep the array shape defensively. Consequence: the prod-migration cutover domain-flip-via-reapply is fine once parsed — the runbook warn-and-drop fallback is NOT needed for domains.2.
is_static— returnsnullon both routesBoth the
environment_detailsembed AND the flatGET /applicationsreturnis_static: null, even for the genuinely-staticlanding. The 4.1.2 read model doesn't surface it, soraw.is_static === true || === 1→false→ perpetualfalse → truediff. Fix: when liveis_staticisnull/absent (unreadable), do not diff it (warn once per run) rather than coercing tofalse. It degrades to a create-time set (cast#63 still sends it on create) — no longer reconciled on this Coolify.Fix incoming as a single PR (both share the
fetchLive→projectLiveFieldsread-back).Source-verified:
is_staticis genuinely unreadable via the v4.1.2 API — the warn-and-skip in #69 is correctTraced against coollabsio/coolify @ v4.1.2 (prompted by a live probe showing
settings: null,has("settings") == falseon both routes):is_staticis a column onApplicationSetting, reachable only viaApplication::settings()(hasOne,app/Models/Application.php).$with/$appends/$hidden→ thesettingsrelation is never auto-loaded.GET /api/v1/applications(ApplicationsController@applications, ~L118) nor the by-uuidGET /applications/{uuid}(@application_by_uuid, ~L1963) ever->load('settings')— identical handlers, both omit it.@environment_details— the embed cast'sfetchLivereads — eager-loadsapplicationsbut notapplications.settings(ProjectController.php:167).GET .../settingsroute inroutes/api.php; no?include=/?with=param honored anywhere.is_staticis in the API surface only as a write input (create body +PATCH).app/Livewire/Project/Application/General.php:444), never the REST API — which is why the toggle shows while every GET omits it.static_image/ports_exposesare returned and correlate with static, but neither deterministically encodes it (a non-static app can expose 80; nothing clearsstatic_imagewhen static is turned off) — not safe to infer from.Conclusion: there is no API read path for
is_staticon v4.1.2. cast cannot round-trip it, so #69 correctly sets it at create and skips it on diff (with a once-per-run warn). Changing an existing app's static flag is a Coolify UI act cast can't reconcile — documented in the warn.docker_compose_domains, by contrast, IS a realapplicationscolumn (readable, service-keyed object shape), so #69's parse fix handles it normally.PR #69's
is_staticcomments/warn/docs/semantics.mdare updated to cite this source finding.Filed the forward-looking half as #70 — this issue closes with PR #69 (warn-and-skip / create-time-only), #70 tracks (a) the upstream coollabsio/coolify PR that would make
is_staticreadable again (source re-confirmed on the v4.1.2 tag AND current upstreammain: thesettingsrelation is never loaded on any API read; the dashboard only sees it because Livewire reads Eloquent directly), and (b) an--emit-draftgap #69 doesn't touch: a drafted manifest of a live static app silently omitsstatic: trueand UNCAPTURED.md never says so.