feat: place a resource on a destination — and a state file that can say which (#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 file had nowhere to say otherwise, either. 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
this environment", which is the isolation it is meant to provide, inverted.

So:

- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
  `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
  `github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
  app (`core`) from a key that could not tell two projects apart — or even prod's
  app from staging's. The old key is still read (with a warning), so an unmigrated
  state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
  services alike — Coolify runs identical destination logic in all three.

The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
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 as
  with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- 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.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
  could not deploy onto a shared box at all — it did not silently misplace there,
  it simply failed. On a single-destination server the uuid is ignored entirely
  and never validated, so a wrong one is invisible until a second one 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 to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.

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 do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-13 19:31:02 +00:00
parent f7670938ee
commit 8deaeac07b
11 changed files with 1062 additions and 18 deletions

View file

@ -49,7 +49,8 @@ infrastructure can be re-pointed at a new Coolify without touching a product.
```
environments.yaml # bindings: the team each env's token must belong to,
# which server it deploys onto, the S3 destination,
# GitHub App name, smoke target, guards
# GitHub App name, guards — and, per project,
# the destination it deploys onto + its smoke target
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
.coolify/<name>.env # …the same, for a NAMED instance (see below)
@ -65,7 +66,7 @@ cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast smoke [<org>/<repo>] --env <env>
cast team [--env <env>]
```
@ -86,10 +87,12 @@ cast team [--env <env>]
writes the environment's age store from it. See *Adopting a hand-built
instance* below.
- **`server add`** — uploads a server's private key and registers it with Coolify.
- **`smoke`** — contract test against `smoke_target`: proves Coolify's bulk env
endpoint still *upserts* rather than replacing. Run it after every Coolify
upgrade — `apply`'s never-delete guarantee rests on that behavior, and the
published OpenAPI does not describe it accurately.
- **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
every Coolify upgrade — `apply`'s never-delete guarantee rests on that behavior,
and the published OpenAPI does not describe it accurately. Pass the repo whose
target you mean; without one, only the deprecated state-file-scoped
`smoke_target` can answer.
- **`team`** — prints the team the configured token acts as. With `--env`, also
checks it against that environment's `team:` binding and exits non-zero on a
mismatch — the dry run for "would `apply` refuse?", answered without touching
@ -322,6 +325,67 @@ own (it hangs off a project) and no API path scopes by one: **Coolify
environments are an organizational construct, not an auth boundary.** The team
is the only boundary there is, so it is the one cast asserts.
## Two projects, one box: destinations
A **destination** is the Docker network a resource is created on. A server has a
default one, and while a server hosts a single project that default is the right
answer — which is why cast went so long without naming it.
The moment a server hosts *two* projects, it stops being: they share one network,
and "isolated" becomes a thing you believe rather than a thing that is true. So
the destination is declared per **project**, inside the environment — an
environment-scoped key could not express it, because `server:` is precisely the
thing two projects share:
```yaml
environments:
prod:
server: shared-box
team: { id: 1, name: heavy-duty }
projects:
heavy-duty/incubator:
destination_uuid: <uuid> # the network THIS project's resources go on
smoke_target: core # the app `cast smoke` writes its canary to
acme/client-site:
destination_uuid: <other>
```
Keyed by repo, full `<org>/<repo>` slug first, exactly like `github_apps` — a
bare `<repo>` key still resolves, so existing state files keep working. Both
fields are optional, and an environment whose server hosts one project needs
neither.
A **UUID and not a name**, unlike `server:` right above it. Coolify 4.1.2 has no
destinations API whatsoever — no list, no read, nothing — so there is no name for
cast to resolve. You read the UUID out of the Coolify UI, the same way you do for
`s3_destination`.
**What cast can and cannot promise here.** It sends `destination_uuid` on create,
for applications, databases and services alike. It can never check it afterwards:
Coolify takes a UUID on write and hands back an integer `destination_id` on read,
and nothing maps between them. So `diff` does the one honest thing left — it
groups the live resources by the id Coolify *does* report, and a project whose
resources do not all share one network is **drift**:
```
split placement: these resources sit on 2 different destinations
destination 1: application landing, database postgres
destination 4: application core
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).
```
…and when you declare a destination, every `diff` says, out loud, that it did not
verify it. That is deliberate. A setting that reads back as *absent* rather than
*wrong* is the failure this whole file keeps trying not to be.
One sharp edge worth knowing: on a server with exactly **one** destination,
Coolify ignores the `destination_uuid` you send and never validates it — a typo
there is invisible until a second destination exists. On a server with more than
one, a create that omits it is a hard `400`, which is why cast could not deploy
onto a shared box at all until it could send this. Details, with citations:
[reference/README.md](reference/README.md).
## Guarding an environment
An environment may refuse variables by name pattern:

View file

@ -158,6 +158,46 @@ softened by an implementation detail):
refuses `--path` combined with `--env prod`: prod always reads the default
branch, so a feature-branch checkout can never reach it.
## Placement (destinations)
A **destination** is the Docker network a resource is created on. It is declared
per project — `environments.<env>.projects.<repo>.destination_uuid` — because a
destination is scoped project × environment, and the environment block above it
says `server:`, which is exactly what two projects share.
**Enforced once, at create.** `apply` sends `destination_uuid` on every create
(applications, databases, services — Coolify runs identical destination logic in
all three controllers). It is never sent on update, and `apply` never moves a
live resource between networks.
**Not comparable, and therefore reported rather than compared.** Coolify 4.1.2
accepts a `destination_uuid` on write and returns a `destination_id` (an integer
primary key) on read, exposes no endpoint mapping one to the other, and in fact
has no destinations API at all (zero routes at `v4.1.2`). So the declared UUID
**cannot be verified against the live resource it was sent for** — by cast or by
anything else. Two consequences, both deliberate:
- `diff` never diffs the destination as a field. Doing so would compare a UUID
against an int and report drift that could never be resolved — a phantom
"update" on every run.
- `diff` instead groups live resources by the `destination_id` Coolify *does*
report. That int is opaque, but it is comparable **to itself**, which catches
the thing worth catching: a project whose resources do not all sit on one
network is a project whose isolation is broken. That is `split placement`, and
it is drift — non-clean, reported, and **not repaired** (same disposition as an
orphan).
- Whenever a destination is declared, `diff` says explicitly that it was *not*
compared. Silence would make an unverified setting read as a verified one —
the failure shape this document exists to avoid.
**Coolify's create-time behavior** (`ApplicationsController` ~L1003,
`DatabasesController` ~L1700, `ServicesController` ~L378 @ v4.1.2): a server with
**one** destination uses it and *ignores* any `destination_uuid` sent, never
validating it — so a typo is invisible there. A server with **more than one**
rejects a create that omits it (`400`), and rejects a UUID that belongs to
another server (`422`). The second case is why cast could not apply to a shared
box at all before this field existed. Citations: `reference/README.md`.
## Instance selection
**The Coolify a command talks to is an explicit, named value** — not a property

View file

@ -28,3 +28,47 @@ Storages). No S3 storage API exists in 4.1.2 — the destination UUID is
recorded in environments.yaml by the bootstrap runbook; the client
deliberately has no storage resolver. Re-check on any Coolify upgrade
re-vendor.
## Known gap: a resource's destination is write-only (verified 2026-07-13)
The **destination** (the Docker network a resource is created on — a
`StandaloneDocker`, not the S3 thing above) can be *set* by the API and never
*read back*. Three separate facts, all verified at tag `v4.1.2`:
- **No destinations API at all.** `routes/api.php` has zero routes matching
`destination` — not list, not read, not create. So a destination name cannot
be resolved to anything; only a raw UUID, read out of the UI, identifies one.
(Same shape as the S3 gap above, same consequence: `environments.yaml` records
the UUID, and cast has no resolver.)
- **Write accepts `destination_uuid`** on create for applications
(`/applications/*`), all eight database types, and `/services`; and on PATCH
for applications and services.
- **Read returns `destination_id` + `destination_type`** — the integer primary
key and the morph class (`App\Models\StandaloneDocker`), never the UUID. They
survive serialization (none of the three controllers' `removeSensitiveData()`
hides them), but `ProjectController@environment_details` does not eager-load
the `destination` relation, and nothing else exposes it.
**Nothing maps an int to a UUID**, so a declared `destination_uuid` cannot be
compared against the live resource it was sent for. `diff` therefore *reports*
placement rather than comparing it (see `Placement` in `src/diff.ts`): it groups
live resources by `destination_id` — which is comparable to itself — so a
project whose resources do not all share one network is still caught, and it
says plainly that the declared UUID was not verified.
Coolify's own create-time behavior, identical in `ApplicationsController`
(~L1003), `DatabasesController` (~L1700) and `ServicesController` (~L378):
| server has | `destination_uuid` sent | result |
| --- | --- | --- |
| 0 destinations | anything | `400 Server has no destinations.` |
| exactly 1 | omitted | `$destinations->first()` — the default network |
| exactly 1 | **any value** | **ignored, never validated**`first()` again |
| >1 | omitted | `400 Server has multiple destinations and you do not set destination_uuid.` |
| >1 | not on that server | `422 Provided destination_uuid does not belong to the specified server.` |
Two consequences worth keeping in mind. A **single-destination server silently
accepts a wrong UUID** — neither Coolify nor cast can catch that, and it is why
the declared value is never trusted as verified. And a **multi-destination
server rejects a create that omits it**, which is why cast could not deploy onto
a shared box at all until it could send this field.

View file

@ -22,6 +22,42 @@ const TeamSchema = z
message: "team must give at least one of `id` or `name`",
});
// State that belongs to ONE project inside ONE environment — not to the
// environment as a whole.
//
// The environment block above it is the wrong scope for any of this, and was
// always going to be: it says `server:`, and a server is exactly the thing two
// projects can share. Everything here is keyed the way `github_apps` is (see
// githubAppNameFor) — by the repo, full `<org>/<repo>` slug first — because the
// repo is what identifies a project to US. The Coolify project NAME is theirs,
// it is what someone typed into a UI, and `--project` exists precisely because
// it does not have to match.
const ProjectBindingSchema = z
.object({
// The Docker network this project's resources are created on — a raw UUID,
// read out of the Coolify UI, exactly like `s3_destination`.
//
// A UUID and not a name, deliberately, even though `server:` right above is
// a name: Coolify 4.1.2 has NO destinations API at all (zero routes in
// routes/api.php @ v4.1.2), so unlike a server name, cast cannot resolve a
// destination name to anything. A key called `destination` would invite one.
// See reference/README.md.
//
// Optional, and absent means "whatever the server's only destination is" —
// which is correct until the server has two, and is why this went unnoticed:
// Coolify picks `$destinations->first()` when a server has exactly one.
destination_uuid: z.string().optional(),
// The app `cast smoke` writes its canary env vars to. Project-scoped
// because it names one project's application: `core` is incubator's compose
// app, and the day a second project deploys into this environment, an
// environment-scoped (let alone the state-file-scoped one it replaces)
// `smoke_target: core` is simply wrong.
smoke_target: z.string().optional(),
})
.strict();
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
const BindingsSchema = z
.object({
environments: z.record(
@ -58,6 +94,9 @@ const BindingsSchema = z
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
// whatever family of flags enables destructive tooling.
forbidden_var_patterns: z.array(z.string()).optional(),
// Per-project state, keyed by repo. Optional: an environment whose
// server hosts one project needs none of it.
projects: z.record(ProjectBindingSchema).optional(),
})
.strict(),
),
@ -65,6 +104,12 @@ const BindingsSchema = z
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
// existing state files keep working.
github_apps: z.record(z.string()),
// DEPRECATED — moved to environments.<env>.projects.<repo>.smoke_target.
// Still read (see smokeTargetFor) so state files written before the move
// keep working, on the same reasoning as the bare-`<repo>` github_apps key.
// It is wrong at TWO levels: it names one project's app (`core`) from a key
// scoped to the whole state file, so it cannot distinguish two projects and
// cannot distinguish prod's app from staging's either.
smoke_target: z.string().optional(),
})
.strict();
@ -104,6 +149,45 @@ export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
return name;
}
// The project-scoped bindings for one repo in one environment, or undefined if
// the environment declares none. Same full-slug-then-bare-repo lookup as
// githubAppNameFor, for the same reason — see the note there.
//
// Absence is NOT an error: `projects:` is optional, and an environment with a
// single project on a single-destination server has nothing to say here. The
// callers that genuinely need a value (smoke) say so themselves.
export function projectBindingFor(
bindings: Bindings,
envName: string,
orgRepo: string,
): ProjectBinding | undefined {
const projects = bindings.environments[envName]?.projects;
if (!projects) return undefined;
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
return projects[orgRepo] ?? projects[repoShort];
}
// The app `cast smoke` targets. Project-scoped first; the deprecated
// state-file-scoped `smoke_target` is the fallback, so an unmigrated state file
// still smokes.
//
// `orgRepo` is optional because `cast smoke` did not take one until the target
// became project-scoped — without it there is no project to look up and only
// the old key can answer, which is exactly what the old invocation did.
export function smokeTargetFor(
bindings: Bindings,
envName: string,
orgRepo?: string,
): { target: string; source: "project" | "deprecated" } | undefined {
const scoped = orgRepo
? projectBindingFor(bindings, envName, orgRepo)?.smoke_target
: undefined;
if (scoped) return { target: scoped, source: "project" };
if (bindings.smoke_target)
return { target: bindings.smoke_target, source: "deprecated" };
return undefined;
}
export function loadBindings(
path: string,
opts: { overrideText?: string } = {},

View file

@ -5,7 +5,12 @@ import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
import { githubAppNameFor, loadBindings } from "./bindings.js";
import {
githubAppNameFor,
loadBindings,
projectBindingFor,
smokeTargetFor,
} from "./bindings.js";
import {
type LiveEnvs,
absentResources,
@ -50,7 +55,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--generated <NAME>] [--override <NAME>] [--force]
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast smoke [<org>/<repo>] --env <env>
cast team [--env <env>]
--state <dir> the state checkout holding environments.yaml, secrets/ and
@ -316,6 +321,14 @@ export async function fetchLive(
uuid: String(i.uuid),
fields: projectLiveFields(kind, i),
env: undefined, // populated per-resource below only in full mode by caller
// The one thing Coolify will tell us about placement. `destination_id` is
// a plain column on all three resource tables and none of the three
// controllers' removeSensitiveData() hides it (v4.1.2), so it survives
// into this response — whereas the destination's UUID never appears in
// any response at all, because environment_details does not eager-load
// the `destination` relation and no endpoint exposes it. See Placement.
destinationId:
typeof i.destination_id === "number" ? i.destination_id : undefined,
}));
return {
found: true,
@ -603,6 +616,10 @@ async function main(): Promise<number> {
return 2;
}
assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns);
// Keyed by the REPO, not by --project: --project is the name Coolify's own
// UI happens to use for this project, and cast's state is keyed by the name
// WE own (same split as the secrets file — see the --project note above).
const projectBinding = projectBindingFor(bindings, envName, orgRepo);
if (values["hostname-overlay"]) {
desired = applyHostnameOverlay(
desired,
@ -655,7 +672,9 @@ async function main(): Promise<number> {
l.env = await fetchEnv(client, l);
}
}
const report = computeDiff(desired, live, mode);
const report = computeDiff(desired, live, mode, {
declaredDestination: projectBinding?.destination_uuid,
});
console.log(renderDiff(report));
if (command === "diff") return report.clean ? 0 : 1;
const serverUuid = await client.serverUuid(binding.server);
@ -670,6 +689,7 @@ async function main(): Promise<number> {
envName: coolifyEnv,
serverUuid,
githubAppUuid,
destinationUuid: projectBinding?.destination_uuid,
s3DestinationUuid: binding.s3_destination,
backupSchedules,
});
@ -986,7 +1006,7 @@ async function main(): Promise<number> {
return 0;
}
if (command === "smoke") {
const { values } = parseArgs({
const { values, positionals } = parseArgs({
args: rest,
allowPositionals: true,
options: {
@ -995,6 +1015,11 @@ async function main(): Promise<number> {
instance: { type: "string" },
},
});
// Optional, unlike every other verb's — and only because the target used to
// be state-file-scoped, so `cast smoke --env prod` with no repo at all is
// what the runbook says today. Without it, only the deprecated key can
// answer; with it, the project-scoped one can.
const smokeRepo = positionals[0];
// smoke writes: it POSTs two env vars onto the live smoke_target app and
// deletes them again. That is a mutation, so it takes the assert like any
// other. Without it, a wrong-team token that happened to own an app of
@ -1018,20 +1043,53 @@ async function main(): Promise<number> {
assertWritable(instance, "smoke");
const team = await assertTeam(client, binding.team, values.env);
console.log(`team ${formatTeam(team)}`);
if (!bindings.smoke_target) {
const resolved = smokeTargetFor(bindings, values.env, smokeRepo);
if (!resolved) {
const lookedFor = smokeRepo
? [
` looked for: environments.${values.env}.projects["${smokeRepo}"].smoke_target`,
" then the deprecated state-file-scoped smoke_target",
]
: [
" looked for: the deprecated state-file-scoped smoke_target — and only",
" that one, because no <org>/<repo> was given and so no",
" project's binding could be consulted",
];
console.error(
"environments.yaml: smoke_target (app name) required for smoke",
[
`no smoke_target for ${values.env}`,
"",
...lookedFor,
"",
"`smoke` writes two canary env vars to one application and deletes them",
"again — it has to be told which one. Name it under the project it belongs",
"to, and pass that repo:",
"",
" environments:",
` ${values.env}:`,
" projects:",
` ${smokeRepo ?? "<org>/<repo>"}:`,
" smoke_target: <the application's name>",
].join("\n"),
);
return 2;
}
// resolve app uuid by name across the project list
if (resolved.source === "deprecated") {
console.warn(
`warning: smoke_target read from the deprecated state-file-scoped key. It names ONE project's application from a key that cannot tell two projects — or even prod from staging — apart. Move it to environments.${values.env}.projects.<org>/<repo>.smoke_target and pass the repo to \`cast smoke\`.`,
);
}
// Resolved against the instance-wide application list, not the project's:
// that is what it did before this change and it is not this change's job to
// alter which app gets written to. It does mean two projects with an app of
// the same name are a coin flip — filed separately.
const apps = (await client.get("/applications")) as Array<{
uuid: string;
name: string;
}>;
const target = apps.find((a) => a.name === bindings.smoke_target);
const target = apps.find((a) => a.name === resolved.target);
if (!target) {
console.error(`smoke_target ${bindings.smoke_target} not found`);
console.error(`smoke_target ${resolved.target} not found`);
return 2;
}
await smoke(client, target.uuid);
@ -1191,10 +1249,38 @@ export function buildExecutor(
envName: string;
serverUuid: string;
githubAppUuid: string;
// The Docker network to create resources on. A raw UUID from
// environments.yaml for the same reason s3DestinationUuid is one: Coolify
// 4.1.2 has no destinations API, so there is no name for cast to resolve.
//
// Create-time ONLY, and every kind gets it (Coolify's three controllers run
// identical destination logic). Undefined means "the server's only
// destination", which is what Coolify picks anyway — and, until a server
// hosts two projects, is the right answer.
destinationUuid?: string;
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
backupSchedules: Record<string, { frequency: string; retention: number }>;
},
): Executor {
// Coolify resolves this identically for applications, databases and services
// (ApplicationsController ~1003, DatabasesController ~1700,
// ServicesController ~378 @ v4.1.2):
//
// 0 destinations -> 400, whatever we send
// >1 and no destination_uuid -> 400 "Server has multiple destinations and
// you do not set destination_uuid"
// >1 and a foreign uuid -> 422 "does not belong to the specified server"
// exactly 1 -> $destinations->first(), and anything we
// send here is IGNORED, not validated
//
// So this field is what makes cast able to create resources on a server that
// has more than one destination AT ALL — without it, apply simply 400s there,
// which is the state of things before this change. On a single-destination
// server it is inert (and so, note, a WRONG uuid is silently accepted there —
// nothing on either side can catch that; see renderDiff's placement note).
const destination = ctx.destinationUuid
? { destination_uuid: ctx.destinationUuid }
: {};
return {
async createResource(change) {
// Field payloads assembled from change.fieldDiffs (desired values):
@ -1207,6 +1293,7 @@ export function buildExecutor(
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
...destination,
github_app_uuid: ctx.githubAppUuid,
name: change.name,
instant_deploy: false,
@ -1228,6 +1315,7 @@ export function buildExecutor(
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
...destination,
name: change.name,
...databaseApiFields(fields),
},
@ -1252,6 +1340,7 @@ export function buildExecutor(
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
...destination,
name: change.name,
...serviceApiFields(fields),
})) as { uuid: string };

View file

@ -13,6 +13,15 @@ export type Live = {
uuid: string;
fields: Record<string, unknown>;
env?: Record<string, string>;
// The destination (Docker network) Coolify reports this resource on.
//
// NOT in `fields`, because `fields` is the desired-vs-live comparison
// vocabulary and this can never take part in it: Coolify 4.1.2 accepts
// `destination_uuid` on write and returns `destination_id` (an integer
// primary key) on read, and exposes no endpoint that maps one to the other.
// Putting it in `fields` would diff a UUID against an int and report drift
// that can never be resolved. See Placement.
destinationId?: number;
};
export type FieldDiff = {
field: string;
@ -33,10 +42,32 @@ export type Change = {
fieldDiffs: FieldDiff[];
envDiffs: EnvDiff[];
};
// Where this project's resources actually sit, as far as Coolify will say.
//
// The destination cannot be diffed the way every other field is (see Live), so
// the alternative was to leave it out of the report entirely — and a setting
// that reads back as ABSENT rather than WRONG is the exact failure shape cast
// keeps legislating against (#12, #14, #17, #18). So it is reported instead of
// compared, and reported with the limit stated:
//
// - `declared` is what the state file asks for. cast sends it on create and
// CANNOT check it afterwards. Never silently — renderDiff says so.
// - `groups` is what Coolify answers, by `destination_id`. It is an opaque
// int, but it is comparable to ITSELF, and that is enough to catch the
// thing actually worth catching: a project whose resources do not all share
// one network is a project whose isolation is broken, whatever the numbers
// happen to be.
export type Placement = {
declared?: string;
groups: { destinationId: number; resources: string[] }[];
split: boolean;
};
export type DiffReport = {
mode: "structural" | "full";
changes: Change[];
orphans: { kind: ResourceKind; name: string; uuid: string }[];
placement: Placement;
clean: boolean;
};
@ -67,10 +98,33 @@ function diffEnv(
return diffs;
}
function computePlacement(live: Live[], declared?: string): Placement {
const byDestination = new Map<number, string[]>();
for (const l of live) {
// Coolify returns destination_id on applications, databases and services
// alike (none of the three controllers' removeSensitiveData hides it,
// v4.1.2). A resource that reports none is not evidence of a split — it is
// no evidence at all, so it is left out rather than grouped under a
// fabricated id.
if (typeof l.destinationId !== "number") continue;
const at = byDestination.get(l.destinationId) ?? [];
at.push(`${l.kind} ${l.name}`);
byDestination.set(l.destinationId, at);
}
const groups = [...byDestination.entries()]
.map(([destinationId, resources]) => ({
destinationId,
resources: resources.sort(),
}))
.sort((a, b) => a.destinationId - b.destinationId);
return { declared, groups, split: groups.length > 1 };
}
export function computeDiff(
desired: Desired[],
live: Live[],
mode: "structural" | "full",
opts: { declaredDestination?: string } = {},
): DiffReport {
const changes: Change[] = [];
for (const d of desired) {
@ -120,11 +174,16 @@ export function computeDiff(
const orphans = live
.filter((l) => !desired.some((d) => d.kind === l.kind && d.name === l.name))
.map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid }));
const placement = computePlacement(live, opts.declaredDestination);
return {
mode,
changes,
orphans,
clean: changes.length === 0 && orphans.length === 0,
placement,
// A split project is drift, and drift is not clean — the same disposition
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
// between networks; see renderDiff).
clean: changes.length === 0 && orphans.length === 0 && !placement.split,
};
}
@ -156,10 +215,39 @@ export function renderDiff(report: DiffReport): string {
`orphan ${o.kind} ${o.name} (live, not in manifest — removal is a manual runbook act)`,
);
}
const { placement } = report;
if (placement.split) {
lines.push(
`split placement: these resources sit on ${placement.groups.length} different destinations`,
);
for (const g of placement.groups)
lines.push(` destination ${g.destinationId}: ${g.resources.join(", ")}`);
lines.push(
" 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).",
);
} else if (placement.declared && placement.groups.length === 1) {
lines.push(
`placement: all resources on destination ${placement.groups[0].destinationId}`,
);
}
if (placement.declared) {
// Said out loud on every run that declares one, rather than left to be
// inferred from its absence. cast enforces this UUID exactly once — at
// create — and can never check it again; an operator who thinks `diff`
// covers it is an operator who thinks the isolation is verified.
lines.push(
`destination ${placement.declared} 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.",
);
}
lines.push(
report.clean
? "clean"
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)`,
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)${
placement.split ? ", split placement" : ""
}`,
);
return lines.join("\n");
}

View file

@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { type Bindings, githubAppNameFor } from "../src/bindings.js";
import {
type Bindings,
type ProjectBinding,
githubAppNameFor,
loadBindings,
projectBindingFor,
smokeTargetFor,
} from "../src/bindings.js";
function bindings(github_apps: Record<string, string>): Bindings {
return {
@ -10,6 +17,24 @@ function bindings(github_apps: Record<string, string>): Bindings {
} as Bindings;
}
function withProjects(
projects: Record<string, ProjectBinding>,
smoke_target?: string,
): Bindings {
return {
environments: {
prod: {
server: "shared-box",
team: { id: 0, name: "Root Team" },
projects,
},
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
},
github_apps: {},
...(smoke_target ? { smoke_target } : {}),
} as Bindings;
}
describe("githubAppNameFor", () => {
it("resolves the full <org>/<repo> slug", () => {
const b = bindings({ "heavy-duty/incubator": "hdb-coolify" });
@ -60,3 +85,141 @@ describe("githubAppNameFor", () => {
expect(err).toThrow(/heavy-duty\/other/);
});
});
describe("projectBindingFor", () => {
it("resolves the full slug, and a legacy bare <repo> key", () => {
const bySlug = withProjects({
"heavy-duty/incubator": { destination_uuid: "dest-a" },
});
const byShort = withProjects({ incubator: { destination_uuid: "dest-a" } });
expect(
projectBindingFor(bySlug, "prod", "heavy-duty/incubator")
?.destination_uuid,
).toBe("dest-a");
expect(
projectBindingFor(byShort, "prod", "heavy-duty/incubator")
?.destination_uuid,
).toBe("dest-a");
});
// The reason the destination has to be project-scoped at all: one server, two
// projects, two networks. An environment-scoped key could not say this.
it("gives two projects on one server two different destinations", () => {
const b = withProjects({
"heavy-duty/incubator": { destination_uuid: "dest-incubator" },
"acme/client-site": { destination_uuid: "dest-client" },
});
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-incubator");
expect(
projectBindingFor(b, "prod", "acme/client-site")?.destination_uuid,
).toBe("dest-client");
});
it("prefers the full slug over a colliding bare key", () => {
const b = withProjects({
incubator: { destination_uuid: "legacy" },
"heavy-duty/incubator": { destination_uuid: "dest-a" },
});
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-a");
});
// Absence is not an error: an environment whose server hosts one project has
// nothing to declare, and that is the state of every box today.
it("is undefined for an environment with no projects block", () => {
const b = withProjects({ "heavy-duty/incubator": {} });
expect(projectBindingFor(b, "staging", "heavy-duty/incubator")).toBe(
undefined,
);
expect(projectBindingFor(b, "prod", "heavy-duty/other")).toBe(undefined);
});
});
describe("smokeTargetFor", () => {
it("prefers the project-scoped target", () => {
const b = withProjects(
{ "heavy-duty/incubator": { smoke_target: "core" } },
"old-target",
);
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "core",
source: "project",
});
});
// The state file mid-migration still has only the old key — it must keep
// smoking, exactly as the bare-`<repo>` github_apps key keeps resolving.
it("falls back to the deprecated state-file-scoped key, and says so", () => {
const b = withProjects({}, "old-target");
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "old-target",
source: "deprecated",
});
// ...and with no repo passed at all, which is the old invocation.
expect(smokeTargetFor(b, "prod")).toEqual({
target: "old-target",
source: "deprecated",
});
});
it("is undefined when neither key names a target", () => {
expect(
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
).toBe(undefined);
});
// Two projects, each with its own smoke target: the case the old key could
// not express at all, since it named one app for the whole state file.
it("keeps two projects' smoke targets apart", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
"acme/client-site": { smoke_target: "web" },
});
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")?.target).toBe(
"core",
);
expect(smokeTargetFor(b, "prod", "acme/client-site")?.target).toBe("web");
});
});
describe("BindingsSchema (projects)", () => {
it("parses a project-scoped destination and smoke_target", () => {
const b = loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
smoke_target: core
github_apps: {}
`,
});
expect(projectBindingFor(b, "prod", "heavy-duty/incubator")).toEqual({
destination_uuid: "dest-abc",
smoke_target: "core",
});
});
it("rejects an unknown key under a project (a typo is not a placement)", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination: dest-abc
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
});

View file

@ -90,6 +90,127 @@ describe("computeDiff", () => {
});
});
// The destination can never be diffed the way a field is: Coolify 4.1.2 takes
// destination_uuid on write and returns destination_id on read, with nothing
// mapping between them. So it is REPORTED rather than compared — and the one
// thing that IS comparable (a project's live resources against each other)
// carries the check that matters.
//
// Placement is measured against the live side ALONE, so these fixtures pair each
// live resource with a matching desired one: otherwise every resource is an
// orphan, and `clean` would be false for reasons that have nothing to do with
// the destination.
const want = (name: string) => ({
kind: "application" as const,
name,
fields: {},
});
const got = (name: string, destinationId?: number) => ({
kind: "application" as const,
name,
uuid: `u-${name}`,
fields: {},
destinationId,
});
describe("computeDiff placement", () => {
it("is not split when every resource shares one destination", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b", 3)],
"structural",
);
expect(r.placement.split).toBe(false);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a", "application b"] },
]);
expect(r.clean).toBe(true);
});
// A project whose resources straddle two networks is a project whose
// isolation is broken — drift, not silence. Same disposition as an orphan:
// reported, counted, never repaired.
it("reports a split project as drift, and is not clean", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b", 7)],
"structural",
);
expect(r.placement.split).toBe(true);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a"] },
{ destinationId: 7, resources: ["application b"] },
]);
expect(r.clean).toBe(false);
// ...and apply is not offered a way to "fix" it.
expect(r.changes).toHaveLength(0);
});
// A resource Coolify reports no destination for is no evidence of a split.
it("ignores resources with no destination rather than grouping them", () => {
const r = computeDiff(
[want("a"), want("b")],
[got("a", 3), got("b")],
"structural",
);
expect(r.placement.split).toBe(false);
expect(r.placement.groups).toEqual([
{ destinationId: 3, resources: ["application a"] },
]);
expect(r.clean).toBe(true);
});
it("carries the declared destination through without comparing it", () => {
const r = computeDiff([want("a")], [got("a", 3)], "structural", {
declaredDestination: "dest-abc",
});
expect(r.placement.declared).toBe("dest-abc");
// Declaring one does not make the project dirty — there is nothing to
// compare it against, and a phantom "update" would never clear.
expect(r.clean).toBe(true);
expect(r.changes).toHaveLength(0);
});
});
describe("renderDiff placement", () => {
it("says out loud that a declared destination was NOT compared", () => {
const out = renderDiff(
computeDiff([want("a")], [got("a", 3)], "structural", {
declaredDestination: "dest-abc",
}),
);
expect(out).toContain("dest-abc");
expect(out).toMatch(/NOT compared/);
expect(out).toMatch(/placement: all resources on destination 3/);
});
// The whole reason placement is in the report at all: a destination that read
// back as absent rather than wrong is the failure shape #12/#14/#17/#18 are
// about. Silence is the bug — but so is noise on the happy path.
it("stays silent about placement when nothing is declared and nothing is split", () => {
const out = renderDiff(
computeDiff([want("a")], [got("a", 3)], "structural"),
);
expect(out).not.toMatch(/placement/);
expect(out).toContain("clean");
});
it("names every resource on each side of a split", () => {
const out = renderDiff(
computeDiff(
[want("core"), want("landing")],
[got("core", 3), got("landing", 7)],
"structural",
),
);
expect(out).toMatch(/split placement: these resources sit on 2 different/);
expect(out).toContain("destination 3: application core");
expect(out).toContain("destination 7: application landing");
expect(out).toMatch(/apply never moves a live resource between networks/);
expect(out).toMatch(/split placement$/m);
});
});
describe("renderDiff", () => {
it("never prints secret values", () => {
const live = [

View file

@ -4,6 +4,10 @@ environments:
team: { id: 1, name: heavy-duty }
s3_destination: s3-backups
forbidden_var_patterns: ["^ALLOW_"]
projects:
heavy-duty/widget:
destination_uuid: dest-widget
smoke_target: core
staging:
server: staging-vm
team: { id: 1, name: heavy-duty }

232
test/placement-cli.test.ts Normal file
View file

@ -0,0 +1,232 @@
import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// Placement, end to end: `environments.yaml` -> `cast diff` -> what it prints
// and what it exits with. The unit tests prove each half (bindings resolve a
// project's destination; computeDiff groups the live side by it) — this proves
// they are actually wired to each other, which is the half a type checker
// cannot see.
//
// The box here is the shape #21 is about: ONE server carrying more than one
// project, so the server's default network is no longer the right answer for
// anything on it.
let recipient: string;
let keyFile: string;
beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
});
type Stub = { url: string; close: () => Promise<void> };
const stubs: Stub[] = [];
// destinationIds is the knob: which network Coolify says each app is on. The
// live payload is otherwise a byte-for-byte match for the manifest below, so
// anything the diff reports is placement and nothing else.
async function stubCoolify(destinationIds: {
core: number | null;
landing: number | null;
}): Promise<Stub> {
const app = (name: string, uuid: string, destination_id: number | null) => ({
name,
uuid,
git_repository: "heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: `http://${name}.example.com`,
// Coolify returns this on every resource; `null` stands for the box that
// somehow reports none.
destination_id,
});
const server = createServer((req, res) => {
const path = (req.url ?? "").replace("/api/v1", "");
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/projects") return json([{ uuid: "p1", name: "incubator" }]);
if (path === "/projects/p1/staging")
return json({
applications: [
app("core", "a1", destinationIds.core),
app("landing", "a2", destinationIds.landing),
],
});
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
const MANIFEST = `project: incubator
environments:
staging:
applications:
core:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://core.example.com"]
landing:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://landing.example.com"]
`;
// `declared` is the destination_uuid the state file names for this project —
// undefined means the state file says nothing, which is every state file today.
function fixture(url: string, declared?: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
mkdirSync(join(state, "secrets"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
// No template refs any secret, but the store still has to exist and open —
// diff resolves the environment's secrets before it reads anything live.
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
input: "\n",
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
// Keyed by the full slug, and nested under the environment — the shape
// that can say "this project goes HERE and that one goes THERE".
...(declared
? [
" projects:",
" heavy-duty/incubator:",
` destination_uuid: ${declared}`,
]
: []),
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return { checkout, state };
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "diff", ...args], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const base = (f: { checkout: string; state: string }) => [
"heavy-duty/incubator",
"--env",
"staging",
"--path",
f.checkout,
"--state",
f.state,
];
describe("cast diff — placement (#21)", () => {
it("reports the destination it declared, and says it could not verify it", async () => {
const f = fixture(
(await stubCoolify({ core: 5, landing: 5 })).url,
"d-abc",
);
const r = await run(base(f));
// Clean: a declared destination is never itself drift. There is nothing on
// the wire to compare it to, and a phantom "update" would never clear.
expect(r.code).toBe(0);
expect(r.output).toContain("placement: all resources on destination 5");
// The load-bearing sentence. cast enforces this UUID once, at create, and
// can never check it again — an operator who reads a clean diff as "the
// isolation is verified" is exactly the person #21 is written for.
expect(r.output).toContain("d-abc");
expect(r.output).toMatch(/NOT compared/);
expect(r.output).toContain("clean");
});
// The failure the issue is actually about: the isolation looks configured and
// isn't. Two of this project's apps, two different networks.
it("fails a split project, naming both sides of the split", async () => {
const f = fixture(
(await stubCoolify({ core: 5, landing: 9 })).url,
"d-abc",
);
const r = await run(base(f));
expect(r.code).toBe(1);
expect(r.output).toMatch(/split placement: these resources sit on 2/);
expect(r.output).toContain("destination 5: application core");
expect(r.output).toContain("destination 9: application landing");
// Reported, never repaired — the orphan disposition.
expect(r.output).toMatch(/apply never moves a live resource between/);
expect(r.output).not.toContain("clean");
});
// A split is a split whether or not the state file has caught up: it is read
// off the live side alone. This is what catches a box where someone made the
// destinations by hand and cast has never been told.
it("catches a split even when no destination is declared", async () => {
const f = fixture((await stubCoolify({ core: 5, landing: 9 })).url);
const r = await run(base(f));
expect(r.code).toBe(1);
expect(r.output).toMatch(/split placement/);
// ...and with nothing declared there is no unverifiable claim to warn about.
expect(r.output).not.toMatch(/NOT compared/);
});
// The state of every box today: one project, one server, one network, nothing
// declared. Placement must be silent — a line on every diff that says nothing
// is how a report stops being read.
it("says nothing at all about placement on an undeclared, unsplit box", async () => {
const f = fixture((await stubCoolify({ core: 5, landing: 5 })).url);
const r = await run(base(f));
expect(r.code).toBe(0);
expect(r.output).not.toMatch(/placement/);
expect(r.output).toContain("clean");
});
});

View file

@ -257,6 +257,121 @@ describe("buildExecutor createResource (application, dockercompose)", () => {
});
});
// Placement is create-time only, and every kind needs it: Coolify runs the same
// destination logic in ApplicationsController, DatabasesController and
// ServicesController, and 400s on a multi-destination server for whichever one
// omits it. Missing it on the database create alone would be enough to leave a
// project's Postgres on the shared default network.
describe("buildExecutor createResource (destination placement)", () => {
function captureCreates() {
const bodies: Record<string, Record<string, unknown>> = {};
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (
path === "/api/v1/applications/private-github-app" ||
path === "/api/v1/databases/postgresql" ||
path === "/api/v1/services"
) {
bodies[path] = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "new-1" }), { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
return { bodies, fetchImpl };
}
const creates = [
{
path: "/api/v1/applications/private-github-app",
change: {
kind: "application" as const,
name: "core",
op: "create" as const,
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
{ field: "domains", desired: ["https://a"], updatable: true },
],
envDiffs: [],
},
},
{
path: "/api/v1/databases/postgresql",
change: {
kind: "database" as const,
name: "postgres",
op: "create" as const,
fieldDiffs: [
{ field: "type", desired: "postgresql", updatable: false },
],
envDiffs: [],
},
},
{
path: "/api/v1/services",
change: {
kind: "service" as const,
name: "umami",
op: "create" as const,
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
envDiffs: [],
},
},
];
it.each(creates)(
"sends destination_uuid on the $path create",
async ({ path, change }) => {
const { bodies, fetchImpl } = captureCreates();
const client = new CoolifyClient(
"https://coolify.test",
"tok",
fetchImpl,
);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
destinationUuid: "dest-abc",
backupSchedules: {},
});
await exec.createResource(change);
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
// The server still has to be named — a destination belongs to one.
expect(bodies[path]?.server_uuid).toBe("srv-1");
},
);
// Undeclared must mean ABSENT, not empty-string: Coolify branches on
// `$request->has('destination_uuid')`, so sending "" would take the
// "you gave me one" path and then fail to match any destination.
it.each(creates)(
"omits destination_uuid entirely when none is declared ($path)",
async ({ path, change }) => {
const { bodies, fetchImpl } = captureCreates();
const client = new CoolifyClient(
"https://coolify.test",
"tok",
fetchImpl,
);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
backupSchedules: {},
});
await exec.createResource(change);
expect(bodies[path]).not.toHaveProperty("destination_uuid");
},
);
});
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
it("round-trips through defaultDatabaseImage for postgres", () => {
const image = defaultDatabaseImage("postgresql", "17");