place a resource on a destination — and a state file that can say which (#21) #28

Merged
dan-claude-bot merged 2 commits from feat/destination-placement into main 2026-07-13 19:45:20 +00:00
dan-claude-bot commented 2026-07-13 19:31:39 +00:00 (Migrated from github.com)

Closes #21.

A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default. Invisible and harmless while each server hosts one project — and neither the moment a server hosts two.

The state shape

A destination is scoped project × environment, and environments.<env> is scoped by environment alone. A destination: key there would mean one network shared by every project in the environment — the isolation it is meant to provide, inverted. So the issue's proposal, adopted:

environments:
  prod:
    server: shared-box
    team: { id: 1, name: heavy-duty }
    projects:
      heavy-duty/incubator:
        destination_uuid: <uuid>
        smoke_target: core
      acme/client-site:
        destination_uuid: <other>

Keyed by repo, full <org>/<repo> slug first with a bare-<repo> fallback — the github_apps precedent, for the same reason. Both fields optional; an environment whose server hosts one project declares neither.

smoke_target moves here. It was state-file-scoped (not even per-environment): it named one project's app from a key that could not tell two projects apart, or prod's app from staging's. The old key is still read, with a warning, so an unmigrated state file keeps smoking; cast smoke now takes an optional <org>/<repo>.

apply sends destination_uuid on create — applications, databases and services alike, since Coolify runs identical destination logic in all three controllers.

What the API actually does — and why ask #4 changes shape

Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), written up in reference/README.md:

  • There is no destinations API. Zero routes. A destination cannot be listed, read, or resolved by name — only a raw UUID from the UI identifies one, exactly like s3_destination. Hence the key is destination_uuid:, not destination:: a key called destination sits right under server:, which is a name, and would invite one that cast can never resolve.
  • The field is write-only. Coolify takes destination_uuid on write and returns destination_id (an integer PK) on read, with nothing mapping between them.
  • A multi-destination server hard-400s a create that omits it. So the issue's premise is a little kinder than reality: cast didn't silently misplace resources on such a server — it couldn't deploy there at all. Conversely, on a single-destination server Coolify ignores the uuid entirely and never validates it, so a wrong one is invisible until a second destination exists.

A declared UUID therefore cannot be verified against the resource it was sent for, by cast or by anything else. Diffing it as a field would compare a UUID against an int and report drift that never clears — a phantom update on every run. So it is reported rather than compared, and the limit is stated out loud rather than left to be inferred:

placement: all resources on destination 5
destination jc4ss8w declared, NOT compared — Coolify 4.1.2 takes
  destination_uuid on write and returns destination_id on read, and has no endpoint
  mapping one to the other. cast sends it on create; nothing can verify it after.
clean

Silence here would make an unverified setting read as a verified one — the failure shape #12/#14/#17/#18 are all about, and the one this issue explicitly warns the fix must not reproduce.

What is comparable is the live side to itself. diff groups live resources by the destination_id Coolify does report, and a project whose resources don't all share one network is drift — non-clean, both sides named, never repaired (apply moves nothing between networks):

split placement: these resources sit on 2 different destinations
  destination 5: application core
  destination 9: application landing, application postgres
  a project's resources must share one destination — that is what the isolation IS.
  apply never moves a live resource between networks: resolve manually (runbook act).
0 change(s), 0 orphan(s), split placement

That catches the failure the issue is actually about, including on a box whose destinations were made by hand and cast was never told about. On an undeclared, unsplit box — every box today — placement prints nothing at all.

Not on the migration's path

Nothing here changes what a single-project server does: undeclared means "the server's only destination", which is what Coolify picks anyway. prod-migration is unaffected.

Tests

196 pass (27 new). Unit coverage for the binding resolvers, the placement computation and its rendering, and destination_uuid reaching all three create payloads (and being absent, not empty-string, when undeclared — Coolify branches on $request->has()). Plus test/placement-cli.test.ts, an end-to-end run of the real dist/cli.js against a stub Coolify, which is what proves environments.yaml is actually wired to the report.

Follow-up, not fixed here

smoke resolves its target against the instance-wide application list, so two projects with an app of the same name are a coin flip. Pre-existing, out of scope, worth its own issue.

🤖 Generated with Claude Code

Closes #21. A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default. Invisible and harmless while each server hosts one project — and neither the moment a server hosts two. ## The state shape A destination is scoped **project × environment**, and `environments.<env>` is scoped by environment alone. A `destination:` key there would mean *one network shared by every project in the environment* — the isolation it is meant to provide, inverted. So the issue's proposal, adopted: ```yaml environments: prod: server: shared-box team: { id: 1, name: heavy-duty } projects: heavy-duty/incubator: destination_uuid: <uuid> smoke_target: core acme/client-site: destination_uuid: <other> ``` Keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback — the `github_apps` precedent, for the same reason. Both fields optional; an environment whose server hosts one project declares neither. `smoke_target` moves here. It was **state-file**-scoped (not even per-environment): it named one project's app from a key that could not tell two projects apart, or prod's app from staging's. The old key is still read, with a warning, so an unmigrated state file keeps smoking; `cast smoke` now takes an optional `<org>/<repo>`. `apply` sends `destination_uuid` on create — applications, databases and services alike, since Coolify runs identical destination logic in all three controllers. ## What the API actually does — and why ask #4 changes shape Verified against `coollabsio/coolify` v4.1.2 (`routes/api.php` + the three Api controllers), written up in `reference/README.md`: - **There is no destinations API.** Zero routes. A destination cannot be listed, read, or resolved by name — only a raw UUID from the UI identifies one, exactly like `s3_destination`. Hence the key is `destination_uuid:`, not `destination:`: a key called `destination` sits right under `server:`, which *is* a name, and would invite one that cast can never resolve. - **The field is write-only.** Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with **nothing mapping between them**. - **A multi-destination server hard-400s a create that omits it.** So the issue's premise is a little kinder than reality: cast didn't silently misplace resources on such a server — it *couldn't deploy there at all*. Conversely, on a single-destination server Coolify ignores the uuid entirely and never validates it, so a wrong one is invisible until a second destination exists. A declared UUID therefore **cannot be verified** against the resource it was sent for, by cast or by anything else. Diffing it as a field would compare a UUID against an int and report drift that never clears — a phantom `update` on every run. So it is **reported rather than compared**, and the limit is stated out loud rather than left to be inferred: ``` placement: all resources on destination 5 destination jc4ss8w declared, NOT compared — Coolify 4.1.2 takes destination_uuid on write and returns destination_id on read, and has no endpoint mapping one to the other. cast sends it on create; nothing can verify it after. clean ``` Silence here would make an unverified setting read as a verified one — the failure shape #12/#14/#17/#18 are all about, and the one this issue explicitly warns the fix must not reproduce. What *is* comparable is the live side **to itself**. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources don't all share one network is drift — non-clean, both sides named, never repaired (apply moves nothing between networks): ``` split placement: these resources sit on 2 different destinations destination 5: application core destination 9: application landing, application postgres a project's resources must share one destination — that is what the isolation IS. apply never moves a live resource between networks: resolve manually (runbook act). 0 change(s), 0 orphan(s), split placement ``` That catches the failure the issue is actually about, including on a box whose destinations were made by hand and cast was never told about. On an undeclared, unsplit box — every box today — placement prints nothing at all. ## Not on the migration's path Nothing here changes what a single-project server does: undeclared means "the server's only destination", which is what Coolify picks anyway. `prod-migration` is unaffected. ## Tests 196 pass (27 new). Unit coverage for the binding resolvers, the placement computation and its rendering, and `destination_uuid` reaching all three create payloads (and being *absent*, not empty-string, when undeclared — Coolify branches on `$request->has()`). Plus `test/placement-cli.test.ts`, an end-to-end run of the real `dist/cli.js` against a stub Coolify, which is what proves `environments.yaml` is actually wired to the report. ## Follow-up, not fixed here `smoke` resolves its target against the **instance-wide** application list, so two projects with an app of the same name are a coin flip. Pre-existing, out of scope, worth its own issue. 🤖 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#28
No description provided.