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

Closed
opened 2026-07-15 15:32:41 +00:00 by dan-claude-bot · 3 comments
dan-claude-bot commented 2026-07-15 15:32:41 +00:00 (Migrated from github.com)

Summary

cast diff (and therefore apply) reports the same two field changes on every run against a live Coolify 4.1.2 — even immediately after an apply that wrote them:

update application core
  docker_compose_domains: undefined → {"api":["https://api.heavyduty.builders"],"admin":["https://admin.heavyduty.builders"],"intake":["https://apply.heavyduty.builders"]}
update application landing
  is_static: false → true

The writes land correctly — the apex domains route, the static site serves — but cast's live projection reads them back as undefined / false, so computeDiff sees desired vs undefined/false forever → a spurious PATCH plus a core + landing redeploy on every apply. A no-op re-apply (idempotency) is impossible.

Where

fetchLive (src/cli.ts) reads GET /projects/{uuid}/{env} (@environment_details); its embedded applications[] feed projectLiveFields (src/cli.ts ~L310):

  • docker_compose_domainsparseDockerComposeDomains (~L288) collapses anything that isn't a JSON-encoded [{name,domain}] string to undefined.
  • is_static (~L342) accepts only raw.is_static === true || raw.is_static === 1 — not, e.g., a string "1" / "true".

So either the environment_details embed 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/incubator prod, 2026-07-15, cast @ 95c9bef, first apply had already set both).

Impact

  • No re-apply is ever a no-op → core + landing redeploy needlessly every time (breaks the idempotency guarantee).
  • The prod-migration cutover flips public domains via re-apply — that mechanism can't be trusted until this converges.
  • Not data-affecting; prod is healthy. The symptom is confined to diff/apply convergence.

Diagnostic probe

Run against the live instance to decide the fix — dumps the raw type + value of both fields from both the route fetchLive uses and the flat per-app model:

set -a; . .coolify.env; set +a          # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN
H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN"
PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid')

echo "── what fetchLive reads: GET /projects/{uuid}/{env} (environment_details embed) ──"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \
  | jq '.applications[] | {name, build_pack,
        is_static, is_static_type:(.is_static|type),
        dcd:.docker_compose_domains, dcd_type:(.docker_compose_domains|type)}'

echo "── the flat per-app model: GET /applications ──"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("core|landing";"i")) | {name, build_pack,
        is_static, is_static_type:(.is_static|type),
        dcd:.docker_compose_domains, dcd_type:(.docker_compose_domains|type)}'

(Adjust incubator / prod if the Coolify project or environment name differs.)

Two outcomes → two fixes

Probe result (for core.docker_compose_domains / landing.is_static) Root cause Fix
is_static_type: "string" ("1"/"true"), or dcd present but not the [{name,domain}] JSON cast expects — on either route parse/type bug — the fields ARE exposed, the projection just won't read them widen the is_static truthiness check + parseDockerComposeDomains (and/or read these from the per-app model if the environment_details embed is the sparse one)
both null / false on both routes even though set Coolify 4.1.2 doesn't expose them on this route console.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 so

Notes

  • Two fields, one root (the environment_detailsprojectLiveFields read-back). They may need distinct handling (is_static type coercion vs docker_compose_domains shape), but the diagnosis and the write side are shared.
  • Reported from heavy-duty/incubator prod migration, Task 8 step 6 (the idempotency / docker_compose_domains round-trip HARD-STOP gate).
## Summary `cast diff` (and therefore `apply`) reports the same two field changes on **every** run against a live Coolify 4.1.2 — even immediately after an `apply` that wrote them: ``` update application core docker_compose_domains: undefined → {"api":["https://api.heavyduty.builders"],"admin":["https://admin.heavyduty.builders"],"intake":["https://apply.heavyduty.builders"]} update application landing is_static: false → true ``` The writes **land correctly** — the apex domains route, the static site serves — but cast's live projection reads them back as `undefined` / `false`, so `computeDiff` sees `desired vs undefined/false` forever → a spurious PATCH **plus a `core` + `landing` redeploy on every apply**. A no-op re-apply (idempotency) is impossible. ## Where `fetchLive` (`src/cli.ts`) reads `GET /projects/{uuid}/{env}` (`@environment_details`); its embedded `applications[]` feed `projectLiveFields` (`src/cli.ts` ~L310): - **`docker_compose_domains`** → `parseDockerComposeDomains` (~L288) collapses anything that isn't a JSON-encoded `[{name,domain}]` string to `undefined`. - **`is_static`** (~L342) accepts only `raw.is_static === true || raw.is_static === 1` — not, e.g., a string `"1"` / `"true"`. So either the `environment_details` embed 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/incubator` prod, 2026-07-15, cast @ `95c9bef`, first apply had already set both). ## Impact - No re-apply is ever a no-op → `core` + `landing` redeploy needlessly every time (breaks the idempotency guarantee). - The prod-migration **cutover flips public domains via re-apply** — that mechanism can't be trusted until this converges. - Not data-affecting; prod is healthy. The symptom is confined to `diff`/`apply` convergence. ## Diagnostic probe Run against the live instance to decide the fix — dumps the raw type + value of both fields from **both** the route `fetchLive` uses and the flat per-app model: ```bash set -a; . .coolify.env; set +a # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN" PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid') echo "── what fetchLive reads: GET /projects/{uuid}/{env} (environment_details embed) ──" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \ | jq '.applications[] | {name, build_pack, is_static, is_static_type:(.is_static|type), dcd:.docker_compose_domains, dcd_type:(.docker_compose_domains|type)}' echo "── the flat per-app model: GET /applications ──" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("core|landing";"i")) | {name, build_pack, is_static, is_static_type:(.is_static|type), dcd:.docker_compose_domains, dcd_type:(.docker_compose_domains|type)}' ``` (Adjust `incubator` / `prod` if the Coolify project or environment name differs.) ## Two outcomes → two fixes | Probe result (for `core.docker_compose_domains` / `landing.is_static`) | Root cause | Fix | |---|---|---| | `is_static_type: "string"` (`"1"`/`"true"`), or `dcd` present but not the `[{name,domain}]` JSON cast expects — on **either** route | **parse/type bug** — the fields ARE exposed, the projection just won't read them | widen the `is_static` truthiness check + `parseDockerComposeDomains` (and/or read these from the per-app model if the `environment_details` embed is the sparse one) | | both `null` / `false` on **both** routes even though set | **Coolify 4.1.2 doesn't expose them on this route** | `console.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 so | ## Notes - Two fields, one root (the `environment_details` → `projectLiveFields` read-back). They may need distinct handling (`is_static` type coercion vs `docker_compose_domains` shape), but the diagnosis and the write side are shared. - Reported from `heavy-duty/incubator` prod migration, **Task 8 step 6** (the idempotency / `docker_compose_domains` round-trip HARD-STOP gate).
dan-claude-bot commented 2026-07-15 15:38:09 +00:00 (Migrated from github.com)

Probe result (incubator prod on coolify-box) — two distinct root causes

core    build_pack=dockercompose  is_static=null
        dcd = "{\"api\":{\"domain\":\"https://api.heavyduty.builders\"},\"admin\":{\"domain\":\"https://admin.heavyduty.builders\"},\"intake\":{\"domain\":\"https://apply.heavyduty.builders\"}}"   (string, IDENTICAL on both routes)
landing build_pack=nixpacks       is_static=null   dcd=null

1. 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}] that parseDockerComposeDomains requires (that's the write shape). So !Array.isArray(parsed)undefined → perpetual undefined → {…} 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 — returns null on both routes

Both the environment_details embed AND the flat GET /applications return is_static: null, even for the genuinely-static landing. The 4.1.2 read model doesn't surface it, so raw.is_static === true || === 1false → perpetual false → true diff. Fix: when live is_static is null/absent (unreadable), do not diff it (warn once per run) rather than coercing to false. 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 fetchLiveprojectLiveFields read-back).

## Probe result (incubator prod on `coolify-box`) — two distinct root causes ``` core build_pack=dockercompose is_static=null dcd = "{\"api\":{\"domain\":\"https://api.heavyduty.builders\"},\"admin\":{\"domain\":\"https://admin.heavyduty.builders\"},\"intake\":{\"domain\":\"https://apply.heavyduty.builders\"}}" (string, IDENTICAL on both routes) landing build_pack=nixpacks is_static=null dcd=null ``` ### 1. `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}]` that `parseDockerComposeDomains` requires (that's the *write* shape). So `!Array.isArray(parsed)` → `undefined` → perpetual `undefined → {…}` 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` — returns `null` on both routes Both the `environment_details` embed AND the flat `GET /applications` return `is_static: null`, even for the genuinely-static `landing`. The 4.1.2 read model doesn't surface it, so `raw.is_static === true || === 1` → `false` → perpetual `false → true` diff. **Fix:** when live `is_static` is `null`/absent (unreadable), do **not** diff it (warn once per run) rather than coercing to `false`. 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` → `projectLiveFields` read-back).
dan-claude-bot commented 2026-07-15 16:24:16 +00:00 (Migrated from github.com)

Source-verified: is_static is genuinely unreadable via the v4.1.2 API — the warn-and-skip in #69 is correct

Traced against coollabsio/coolify @ v4.1.2 (prompted by a live probe showing settings: null, has("settings") == false on both routes):

  • is_static is a column on ApplicationSetting, reachable only via Application::settings() (hasOne, app/Models/Application.php).
  • Application has no $with / $appends / $hidden → the settings relation is never auto-loaded.
  • Neither GET /api/v1/applications (ApplicationsController@applications, ~L118) nor the by-uuid GET /applications/{uuid} (@application_by_uuid, ~L1963) ever ->load('settings') — identical handlers, both omit it.
  • @environment_details — the embed cast's fetchLive reads — eager-loads applications but not applications.settings (ProjectController.php:167).
  • No GET .../settings route in routes/api.php; no ?include= / ?with= param honored anywhere. is_static is in the API surface only as a write input (create body + PATCH).
  • The UI reads it in-process via Livewire straight off the model (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_exposes are returned and correlate with static, but neither deterministically encodes it (a non-static app can expose 80; nothing clears static_image when static is turned off) — not safe to infer from.

Conclusion: there is no API read path for is_static on 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 real applications column (readable, service-keyed object shape), so #69's parse fix handles it normally.

PR #69's is_static comments/warn/docs/semantics.md are updated to cite this source finding.

## Source-verified: `is_static` is genuinely unreadable via the v4.1.2 API — the warn-and-skip in #69 is correct Traced against **coollabsio/coolify @ v4.1.2** (prompted by a live probe showing `settings: null`, `has("settings") == false` on both routes): - `is_static` is a column on **`ApplicationSetting`**, reachable only via `Application::settings()` (`hasOne`, `app/Models/Application.php`). - Application has **no `$with` / `$appends` / `$hidden`** → the `settings` relation is never auto-loaded. - Neither `GET /api/v1/applications` (`ApplicationsController@applications`, ~L118) nor the by-uuid `GET /applications/{uuid}` (`@application_by_uuid`, ~L1963) ever `->load('settings')` — identical handlers, both omit it. - `@environment_details` — the embed cast's `fetchLive` reads — eager-loads `applications` but **not** `applications.settings` (`ProjectController.php:167`). - No `GET .../settings` route in `routes/api.php`; no `?include=` / `?with=` param honored anywhere. `is_static` is in the API surface **only as a write input** (create body + `PATCH`). - The UI reads it in-process via Livewire straight off the model (`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_exposes` are returned and *correlate* with static, but neither deterministically encodes it (a non-static app can expose 80; nothing clears `static_image` when static is turned off) — not safe to infer from. **Conclusion:** there is no API read path for `is_static` on 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 real `applications` column (readable, service-keyed object shape), so #69's parse fix handles it normally. PR #69's `is_static` comments/warn/`docs/semantics.md` are updated to cite this source finding.
dan-claude-bot commented 2026-07-15 17:23:24 +00:00 (Migrated from github.com)

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_static readable again (source re-confirmed on the v4.1.2 tag AND current upstream main: the settings relation is never loaded on any API read; the dashboard only sees it because Livewire reads Eloquent directly), and (b) an --emit-draft gap #69 doesn't touch: a drafted manifest of a live static app silently omits static: true and UNCAPTURED.md never says so.

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_static` readable again (source re-confirmed on the v4.1.2 tag AND current upstream `main`: the `settings` relation is never loaded on any API read; the dashboard only sees it because Livewire reads Eloquent directly), and (b) an `--emit-draft` gap #69 doesn't touch: a drafted manifest of a live static app silently omits `static: true` and UNCAPTURED.md never says so.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#68
No description provided.