From 8deaeac07b3b41c071b4642149f7d43f74895213 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 19:31:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20place=20a=20resource=20on=20a=20destina?= =?UTF-8?q?tion=20=E2=80=94=20and=20a=20state=20file=20that=20can=20say=20?= =?UTF-8?q?which=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.` 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..projects.` — per-project state, keyed by repo, full `/` slug first with a bare-`` 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 `/`. - `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 --- README.md | 76 ++++++++++- docs/semantics.md | 40 ++++++ reference/README.md | 44 ++++++ src/bindings.ts | 84 ++++++++++++ src/cli.ts | 107 +++++++++++++-- src/diff.ts | 92 ++++++++++++- test/bindings.test.ts | 165 ++++++++++++++++++++++- test/diff.test.ts | 121 +++++++++++++++++ test/fixtures/environments.yaml | 4 + test/placement-cli.test.ts | 232 ++++++++++++++++++++++++++++++++ test/wire.test.ts | 115 ++++++++++++++++ 11 files changed, 1062 insertions(+), 18 deletions(-) create mode 100644 test/placement-cli.test.ts diff --git a/README.md b/README.md index e84ce29..3b6e2c9 100644 --- a/README.md +++ b/README.md @@ -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/..env.age # age-encrypted values for the ${…} placeholders .coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit) .coolify/.env # …the same, for a NAMED instance (see below) @@ -65,7 +66,7 @@ cast diff / --env [--full] cast capture / --env [--generated ] [--override ] cast inventory / --env cast server add --ip --key --env [--user root] [--port 22] -cast smoke --env +cast smoke [/] --env cast team [--env ] ``` @@ -86,10 +87,12 @@ cast team [--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: # the network THIS project's resources go on + smoke_target: core # the app `cast smoke` writes its canary to + acme/client-site: + destination_uuid: +``` + +Keyed by repo, full `/` slug first, exactly like `github_apps` — a +bare `` 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: diff --git a/docs/semantics.md b/docs/semantics.md index 735f3e2..409bd34 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -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..projects..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 diff --git a/reference/README.md b/reference/README.md index b05d3df..b31aafd 100644 --- a/reference/README.md +++ b/reference/README.md @@ -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. diff --git a/src/bindings.ts b/src/bindings.ts index b8ce0af..c414cc0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 `/` 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; + 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 `` key still resolves (see githubAppNameFor) so // existing state files keep working. github_apps: z.record(z.string()), + // DEPRECATED — moved to environments..projects..smoke_target. + // Still read (see smokeTargetFor) so state files written before the move + // keep working, on the same reasoning as the bare-`` 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 } = {}, diff --git a/src/cli.ts b/src/cli.ts index ea06016..3fd9555 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 / --env [--path ] [-- cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force] cast inventory / --env [--path ] [--project ] [--environment ] cast server add --ip --key --env [--user root] [--port 22] - cast smoke --env + cast smoke [/] --env cast team [--env ] --state 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 { 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 { 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 { envName: coolifyEnv, serverUuid, githubAppUuid, + destinationUuid: projectBinding?.destination_uuid, s3DestinationUuid: binding.s3_destination, backupSchedules, }); @@ -986,7 +1006,7 @@ async function main(): Promise { 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 { 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 { 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 / 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 ?? "/"}:`, + " smoke_target: ", + ].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./.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; }, ): 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 }; diff --git a/src/diff.ts b/src/diff.ts index 115dc4c..04fee78 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -13,6 +13,15 @@ export type Live = { uuid: string; fields: Record; env?: Record; + // 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(); + 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"); } diff --git a/test/bindings.test.ts b/test/bindings.test.ts index 6938282..e9cace5 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -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): Bindings { return { @@ -10,6 +17,24 @@ function bindings(github_apps: Record): Bindings { } as Bindings; } +function withProjects( + projects: Record, + 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 / 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 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-`` 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/); + }); +}); diff --git a/test/diff.test.ts b/test/diff.test.ts index a89a4fe..5534626 100644 --- a/test/diff.test.ts +++ b/test/diff.test.ts @@ -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 = [ diff --git a/test/fixtures/environments.yaml b/test/fixtures/environments.yaml index da81dbb..d3f9cd0 100644 --- a/test/fixtures/environments.yaml +++ b/test/fixtures/environments.yaml @@ -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 } diff --git a/test/placement-cli.test.ts b/test/placement-cli.test.ts new file mode 100644 index 0000000..f20d6b6 --- /dev/null +++ b/test/placement-cli.test.ts @@ -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 }; +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 { + 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((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((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"); + }); +}); diff --git a/test/wire.test.ts b/test/wire.test.ts index e540a9c..2c08fac 100644 --- a/test/wire.test.ts +++ b/test/wire.test.ts @@ -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> = {}; + 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");