fix: the first apply against a fresh multi-destination box (#40, #41) #42

Merged
dan-claude-bot merged 1 commit from fix/dest-and-default-env into main 2026-07-14 17:30:59 +00:00
dan-claude-bot commented 2026-07-14 17:26:01 +00:00 (Migrated from github.com)

Closes #40. Closes #41.

Both were found by the same run — the genuinely-from-nothing apply that #38 was also hiding in, against a box that shares its server with another project. Neither is a bug in what apply does; both are bugs in what it leaves behind and what it says.

#40 — cast removes the default environment it made Coolify create

POST /projects hands a new project Coolify's own default environment, production. #39 taught apply to create the environment its resources actually name — so a project cast creates from nothing now carries two: ours, holding everything, and an empty production that nothing will ever use.

That is precisely the shape that makes a box unreadable later, and we have the live example: on the box being migrated away from, production is empty and everything runs in staging, and "the obvious guess is the wrong one" is a note we had to write down for ourselves.

This is the only delete cast performs, so it has to argue for itself against apply never deletes. What that rule protects is things cast did not make; this is a byproduct of cast's own POST /projects seconds earlier, holding nothing and having never held anything. Three conditions, jointly, or nothing is touched:

Guard Why
cast created the project in this run never a project someone built by hand, whatever it carries
the environment is empty asked of Coolify, not inferred from the guard above — the details route is the only one that eager-loads resources
its name is not ours an --environment production keeps its production: that is where everything is about to live

Best-effort: a delete that fails is reported and never fails an apply that otherwise worked. Tidying is not worth a half-applied run.

#41 — the multi-destination 400 says what to do, and the plan says what it assumed

cast cannot pre-flight this, and that half is not fixable: 4.1.2 serves no destinations API at all, and GET /servers/{uuid} does not carry them either, so a server's destination count is unknowable until a create has been attempted. The diagnosis is what is fixable.

Before, at the first create, after the project and environment had already been made:

POST /applications/private-github-app → 400: {"message":"Server has multiple destinations and you do not set destination_uuid."}

After:

cannot create application core: prod-box has multiple destinations, so a create must say which one to use.

  Coolify said: POST /applications/private-github-app → 400: {"message":"Server has multiple destinations and you do not set destination_uuid."}

Read the destination UUID from the Coolify UI (4.1.2 exposes no API for it) and
declare it as:

    environments.prod.projects.heavy-duty/incubator.destination_uuid

Placement is create-time — a resource cannot be moved between networks later, so a
wrong or missing destination is repaired by delete + recreate, never by a later apply.

Re-run this apply once the UUID is declared: anything it already created (the project,
its environment) is adopted, not made twice — apply reads before it writes.

The server is named as the operator wrote it (not srv-1), Coolify's own words are kept verbatim so the next person's search still works, and the run is re-runnable.

And the assumption behind an undeclared destination is now on screen at the moment it is made:

placement: server's default destination (none declared) — cast sends no destination_uuid,
  so Coolify picks; a server with more than one destination refuses the create outright.

⚠️ This reverses a judgment cast held explicitly"a line on every diff that says nothing is how a report stops being read", the test this replaces. The line does not say nothing: it says which network the next create lands on. It stays on a clean run that creates nothing, too, because the trap is set precisely for projects that are already built — the day their server gains a second destination, every one of them that declared no destination stops being able to create, and nothing will have warned them. Kept to two lines, because the old judgment was not wrong about noise, only about which side of it silence was on.

Tests

294 pass (build + test + check clean). New coverage drives the real behavior, not call counts:

  • #40 — the delete happens on a cast-created project; never on an adopted one; never on an environment that holds anything (the fake Coolify is made to lie about a fresh project holding an app, because a guard only handed the safe case is not a guard); never on --environment production; and a failing delete still creates the resource.
  • #41 — all three create kinds (which one 400s first depends only on manifest order), each asserting the state-file path, the server name, the failing resource, the create-time warning, and Coolify's verbatim message. Plus: every other failure arrives unmolested — a 422 must not get a confident answer about a destination it has nothing to do with.

For the prod migration

This is the message the Task 8 step-4 apply should have produced. The remedy it prints is exactly infra PR #23 — and per D-248/D-252, re-run the installer after this merges.

🤖 Generated with Claude Code

Closes #40. Closes #41. Both were found by the same run — the genuinely-from-nothing apply that #38 was also hiding in, against a box that shares its server with another project. Neither is a bug in what `apply` *does*; both are bugs in what it **leaves behind** and what it **says**. ## #40 — cast removes the default environment it made Coolify create `POST /projects` hands a new project Coolify's own default environment, `production`. #39 taught apply to create the environment its resources actually name — so a project cast creates from nothing now carries **two**: ours, holding everything, and an empty `production` that nothing will ever use. That is precisely the shape that makes a box unreadable later, and we have the live example: on the box being migrated away from, `production` is empty and everything runs in `staging`, and *"the obvious guess is the wrong one"* is a note we had to write down for ourselves. This is **the only delete cast performs**, so it has to argue for itself against *apply never deletes*. What that rule protects is things cast did not make; this is a byproduct of cast's own `POST /projects` seconds earlier, holding nothing and having never held anything. Three conditions, jointly, or nothing is touched: | Guard | Why | | --- | --- | | cast created the project **in this run** | never a project someone built by hand, whatever it carries | | the environment is **empty** | *asked of Coolify*, not inferred from the guard above — the details route is the only one that eager-loads resources | | its name is **not ours** | an `--environment production` keeps its `production`: that is where everything is about to live | Best-effort: a delete that fails is reported and never fails an apply that otherwise worked. Tidying is not worth a half-applied run. ## #41 — the multi-destination 400 says what to do, and the plan says what it assumed cast **cannot** pre-flight this, and that half is not fixable: 4.1.2 serves no destinations API at all, and `GET /servers/{uuid}` does not carry them either, so a server's destination *count* is unknowable until a create has been attempted. The **diagnosis** is what is fixable. Before, at the first create, after the project and environment had already been made: ``` POST /applications/private-github-app → 400: {"message":"Server has multiple destinations and you do not set destination_uuid."} ``` After: ``` cannot create application core: prod-box has multiple destinations, so a create must say which one to use. Coolify said: POST /applications/private-github-app → 400: {"message":"Server has multiple destinations and you do not set destination_uuid."} Read the destination UUID from the Coolify UI (4.1.2 exposes no API for it) and declare it as: environments.prod.projects.heavy-duty/incubator.destination_uuid Placement is create-time — a resource cannot be moved between networks later, so a wrong or missing destination is repaired by delete + recreate, never by a later apply. Re-run this apply once the UUID is declared: anything it already created (the project, its environment) is adopted, not made twice — apply reads before it writes. ``` The server is named as the operator wrote it (not `srv-1`), Coolify's own words are kept verbatim so the next person's search still works, and the run is re-runnable. And the assumption behind an **undeclared** destination is now on screen at the moment it is made: ``` placement: server's default destination (none declared) — cast sends no destination_uuid, so Coolify picks; a server with more than one destination refuses the create outright. ``` ⚠️ **This reverses a judgment cast held explicitly** — *"a line on every diff that says nothing is how a report stops being read"*, the test this replaces. The line does not say nothing: it says which network the next create lands on. It stays on a clean run that creates nothing, too, because the trap is set precisely for projects that are already built — the day their server gains a second destination, every one of them that declared no destination stops being able to create, and nothing will have warned them. Kept to two lines, because the old judgment was not wrong about noise, only about which side of it silence was on. ## Tests 294 pass (`build` + `test` + `check` clean). New coverage drives the real behavior, not call counts: - **#40** — the delete happens on a cast-created project; **never** on an adopted one; **never** on an environment that holds anything (the fake Coolify is made to lie about a fresh project holding an app, because a guard only handed the safe case is not a guard); **never** on `--environment production`; and a failing delete still creates the resource. - **#41** — all three create kinds (which one 400s first depends only on manifest order), each asserting the state-file path, the server name, the failing resource, the create-time warning, and Coolify's verbatim message. Plus: **every other failure arrives unmolested** — a 422 must not get a confident answer about a destination it has nothing to do with. ## For the prod migration This is the message the Task 8 step-4 apply should have produced. The remedy it prints is exactly [infra PR #23](https://github.com/heavy-duty/infra/pull/23) — and per D-248/D-252, **re-run the installer after this merges**. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
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#42
No description provided.