cast cannot place a resource on a destination — and the state file has nowhere to say which #21

Closed
opened 2026-07-13 18:36:03 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-13 18:36:03 +00:00 (Migrated from github.com)

The gap

cast cannot place a resource on a chosen destination, and the state file has
nowhere to say which one it should be on.

apply creates every application with project_uuid, environment_name and
server_uuid — and no destination_uuid (buildExecutor, src/cli.ts). Coolify
therefore puts everything on the server's default destination: one Docker
network, shared by every project on that server.

This is invisible and harmless today, because each of our servers hosts exactly
one project. It stops being either the moment a server hosts two.

The trap it sets right now

An operator who defines destinations by hand in the Coolify UI — the natural
thing to do when you want two projects isolated — will find that apply ignores
them
. Resources land on the server's default network anyway, nothing reports a
mismatch, and diff doesn't compare the field because the manifest cannot
express it. The isolation looks configured and isn't.

That is the failure shape cast keeps legislating against (#12, #14, #17, #18): a
setting that reads back as absent rather than wrong.

The motivating case

A live migration (D-193). The box being drained hosts three projects — the one
being migrated, plus two unrelated third-party client sites. Today they share the
old box's default network. When they are eventually consolidated onto a single new
box, they must not share one: a per-project destination per environment is
exactly the mechanism, and neither cast nor the state repo can currently say so.

Note what is NOT needed: per-environment isolation on its own. Prod and staging
already land on different servers, and a server's default destination is its
own network — so that isolation comes for free from the server split. The
requirement is specifically project isolation on a shared server.

The state-shape problem it forces

A destination is scoped project × environment. environments.yaml is scoped
by environment alone:

environments:
  prod:
    server: prod-box
    team: { id: 0, name: Root Team }
    s3_destination: <uuid>

Adding destination: there would mean one destination per environment, shared by
every project in it
— which is precisely the isolation this is meant to provide,
inverted.

So this issue is the forcing function for a structure change that is already
overdue. smoke_target: core has the same defect (it names incubator's compose
app, under a key that claims to be about the environment), and github_apps has
already been fixed the right way — keyed by repo. The destination belongs in
the same place:

environments:
  prod:
    server: prod-box
    projects:
      incubator:
        destination: <uuid>     # or a name cast resolves
        smoke_target: core

...or the manifest declares its own destination name and cast resolves it per
environment. Both are defensible; the current shape is not, once there are two
projects.

Proposal

  1. Let a resource be placed on a named destination — destination_uuid on
    create, sourced from state (or the manifest) rather than defaulted.
  2. Give the state file a project-scoped place to say which, rather than
    overloading the per-environment block. github_apps.<repo> is the precedent.
  3. Move smoke_target there in the same pass — it is project-scoped state living
    in an environment-scoped key today, and it will be simply wrong the day a
    second project deploys into prod.
  4. diff should compare the destination once it can be declared, so a resource
    quietly sitting on the wrong network is drift rather than silence.

Not urgent

Deliberately not on the prod-migration critical path. One project per server
today ⇒ the default destination is correct ⇒ this changes nothing about the
migration. It is needed before two projects share a box, which is a separate
piece of work on its own clock. Filed now so that it is a decision rather than a
discovery.

## The gap cast cannot place a resource on a chosen **destination**, and the state file has nowhere to say which one it should be on. `apply` creates every application with `project_uuid`, `environment_name` and `server_uuid` — and no `destination_uuid` (`buildExecutor`, `src/cli.ts`). Coolify therefore puts everything on the **server's default destination**: one Docker network, shared by every project on that server. This is invisible and harmless today, because each of our servers hosts exactly one project. It stops being either the moment a server hosts two. ## The trap it sets right now An operator who defines destinations by hand in the Coolify UI — the natural thing to do when you want two projects isolated — will find that `apply` **ignores them**. Resources land on the server's default network anyway, nothing reports a mismatch, and `diff` doesn't compare the field because the manifest cannot express it. The isolation looks configured and isn't. That is the failure shape cast keeps legislating against (#12, #14, #17, #18): a setting that reads back as *absent* rather than *wrong*. ## The motivating case A live migration (D-193). The box being drained hosts **three** projects — the one being migrated, plus two unrelated third-party client sites. Today they share the old box's default network. When they are eventually consolidated onto a single new box, they must **not** share one: a per-project destination per environment is exactly the mechanism, and neither cast nor the state repo can currently say so. Note what is NOT needed: per-environment isolation on its own. Prod and staging already land on **different servers**, and a server's default destination is its own network — so that isolation comes for free from the server split. The requirement is specifically **project isolation on a shared server**. ## The state-shape problem it forces A destination is scoped **project × environment**. `environments.yaml` is scoped by **environment alone**: ```yaml environments: prod: server: prod-box team: { id: 0, name: Root Team } s3_destination: <uuid> ``` Adding `destination:` there would mean *one destination per environment, shared by every project in it* — which is precisely the isolation this is meant to provide, inverted. So this issue is the forcing function for a structure change that is already overdue. `smoke_target: core` has the same defect (it names *incubator's* compose app, under a key that claims to be about the environment), and `github_apps` has already been fixed the right way — **keyed by repo**. The destination belongs in the same place: ```yaml environments: prod: server: prod-box projects: incubator: destination: <uuid> # or a name cast resolves smoke_target: core ``` ...or the manifest declares its own destination *name* and cast resolves it per environment. Both are defensible; the current shape is not, once there are two projects. ## Proposal 1. Let a resource be placed on a named destination — `destination_uuid` on create, sourced from state (or the manifest) rather than defaulted. 2. Give the state file a **project-scoped** place to say which, rather than overloading the per-environment block. `github_apps.<repo>` is the precedent. 3. Move `smoke_target` there in the same pass — it is project-scoped state living in an environment-scoped key today, and it will be simply wrong the day a second project deploys into `prod`. 4. `diff` should compare the destination once it can be declared, so a resource quietly sitting on the wrong network is drift rather than silence. ## Not urgent **Deliberately not on the prod-migration critical path.** One project per server today ⇒ the default destination is correct ⇒ this changes nothing about the migration. It is needed before two projects share a box, which is a separate piece of work on its own clock. Filed now so that it is a decision rather than a discovery.
dan-claude-bot commented 2026-07-13 19:43:30 +00:00 (Migrated from github.com)

Verified against the vendored reference/coolify-openapi-4.1.2.json — posting before this gets rediscovered, since a parallel session is on this issue.

Destinations cannot be CREATED via the API

There are zero /destinations paths and no Destination schema in 4.1.2. A destination is a UI act, full stop.

That is survivable and has precedent: the S3 storage destination and the GitHub App are both created by hand and then recorded into environments.yaml by UUID, precisely because 4.1.2 has no API to resolve them by name. Destinations should follow the same pattern — create by hand, record the UUID in state, let cast use it.

But resources CAN be placed on one

destination_uuid is accepted on:

  • POST /applications/{dockerfile,dockerimage,public,private-deploy-key,private-github-app}
  • POST /databases/{postgresql,redis,mysql,mariadb,mongodb,keydb,dragonfly,clickhouse}
  • POST /services
  • PATCH /applications/{uuid} · PATCH /services/{uuid}

So placement is fully automatable. cast just cannot mint the network.

The trap: placement is barely verifiable

This is the part worth designing around rather than discovering:

write destination_uuid — a string uuid
read back (application, service) destination_type + destination_id — an integer
read back (databases) nothing at allStandalonePostgresql has no destination field

Two consequences:

  1. diff cannot detect destination drift. You write a uuid and read back an int, and with no list endpoint there is no way to build the uuid↔id mapping. Either state records both (uuid to write, id to compare — ugly but honest), or destination is excluded from the diff and treated as create-only, the way service domains already is.
  2. A database's destination is create-only and unverifiable — same class as connect_to_docker_network, which cast already emits on create only.

Net: destination is declare-once, place-on-create, mostly unverifiable. Getting it wrong is corrected by recreating the resource, not by re-applying — so the create path is the one that has to be right, and a silent default (today: the server's default network) is the failure mode.

Not urgent for the migration in flight

One project per server ⇒ the default destination is correct ⇒ nothing about the current prod migration changes. This bites when two projects share a box.

Verified against the vendored `reference/coolify-openapi-4.1.2.json` — posting before this gets rediscovered, since a parallel session is on this issue. ## Destinations cannot be CREATED via the API There are **zero** `/destinations` paths and **no `Destination` schema** in 4.1.2. A destination is a **UI act**, full stop. That is survivable and has precedent: the **S3 storage destination** and the **GitHub App** are both created by hand and then recorded into `environments.yaml` **by UUID**, precisely because 4.1.2 has no API to resolve them by name. Destinations should follow the same pattern — *create by hand, record the UUID in state, let cast use it.* ## But resources CAN be placed on one `destination_uuid` is accepted on: - `POST /applications/{dockerfile,dockerimage,public,private-deploy-key,private-github-app}` - `POST /databases/{postgresql,redis,mysql,mariadb,mongodb,keydb,dragonfly,clickhouse}` - `POST /services` - `PATCH /applications/{uuid}` · `PATCH /services/{uuid}` So placement is fully automatable. cast just cannot mint the network. ## The trap: placement is barely verifiable This is the part worth designing around rather than discovering: | | | | --- | --- | | **write** | `destination_uuid` — a **string uuid** | | **read back (application, service)** | `destination_type` + **`destination_id`** — an **integer** | | **read back (databases)** | **nothing at all** — `StandalonePostgresql` has no destination field | Two consequences: 1. **`diff` cannot detect destination drift.** You write a uuid and read back an int, and with no list endpoint there is no way to build the uuid↔id mapping. Either state records **both** (uuid to write, id to compare — ugly but honest), or destination is excluded from the diff and treated as create-only, the way service `domains` already is. 2. **A database's destination is create-only and unverifiable** — same class as `connect_to_docker_network`, which cast already emits on create only. Net: destination is **declare-once, place-on-create, mostly unverifiable**. Getting it wrong is corrected by **recreating the resource**, not by re-applying — so the create path is the one that has to be right, and a silent default (today: the server's default network) is the failure mode. ## Not urgent for the migration in flight One project per server ⇒ the default destination is correct ⇒ nothing about the current prod migration changes. This bites when two projects share a box.
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#21
No description provided.