From 2e201fb58a6055074c8f59d9bf5c67b6a9f76bb3 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 22:30:34 +0000 Subject: [PATCH] fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify enforces domain uniqueness across the whole instance; cast plans inside one project + one environment. So apply could produce a plan that was internally consistent, correct against everything cast can observe, and still be refused — by a resource in a project cast never queries, arriving as a raw 409 mid-apply, after the project and the environment had already been created. - Pre-flight the create plan: before the first write (project and environment are created lazily, by the first create), check the domains the plan is about to claim against GET /applications. A conflict is now a refusal that costs nothing, not a half-applied run. One GET, and only on a plan that creates an application with a domain — a first apply. Covers both live shapes: fqdn, and per-service docker_compose_domains. - Translate the 409 when one gets through anyway (a conflict with a service fqdn or the instance fqdn is not visible in GET /applications, so the pre-flight is a strict subset of Coolify's check). Names the domain, the resource, its uuid — and whether it is outside the applied project, which is the part the operator cannot get from Coolify. - Never send force_domain_override=true. Coolify suggests it in the error text; two resources on one domain is a routing coin-flip, and Coolify says so in the same response. Closes #44. Co-Authored-By: Claude Opus 4.8 --- docs/semantics.md | 44 ++++ src/cli.ts | 337 ++++++++++++++++++++++++- src/coolify.ts | 26 ++ test/domain-preflight.test.ts | 445 ++++++++++++++++++++++++++++++++++ 4 files changed, 848 insertions(+), 4 deletions(-) create mode 100644 test/domain-preflight.test.ts diff --git a/docs/semantics.md b/docs/semantics.md index 58cb803..c02ff79 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -370,6 +370,50 @@ 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`. +## Domains (uniqueness is instance-wide) + +**Coolify enforces domain uniqueness across the whole instance; cast plans inside one +project and one environment.** That gap is structural, not a bug: a plan can be +internally consistent, correct against everything cast can observe, and still be +refused — by a resource in a project cast never queries. The check is +`checkIfDomainIsAlreadyUsedViaAPI` (`bootstrap/helpers/domains.php` @ v4.1.2) and it +walks every application of the *team* (its `fqdn`, and for `dockercompose` apps its +per-service `docker_compose_domains`), every service application's `fqdn`, and the +instance's own `fqdn`. Only applications can claim a domain through cast: databases +have none, and cast's service creates send no domains at all. + +**The create plan is pre-flighted** (#44). Before `apply` writes anything — the +project and the environment are created lazily, by the first create, so this is the +last moment a refusal is free — cast reads `GET /applications` and checks the domains +the plan is about to claim against every one already held. A conflict is a **refusal** +(nothing created), not a failed apply. It costs one GET, and only on a plan that +creates an application with a domain: a first apply, and nothing else. *N+1 is not +needed:* the list is serialized by the same `removeSensitiveData()` as the per-app +`GET` (`ApplicationsController.php` :38, called at :130 and :1980), so it already +carries `fqdn`, `docker_compose_domains` and `build_pack` — none of which the vendored +OpenAPI documents on that route. + +**The 409 is translated when one gets through anyway.** The pre-flight is a *subset* +of Coolify's check — service `fqdn`s and the instance `fqdn` appear in no list cast +can read — so a create can still be refused mid-apply, with a raw +*"Domain conflicts detected. Use force_domain_override=true to proceed."* Both the +refusal and the translation say the same three things, the last of which is the one +the operator cannot get from Coolify: the domain, the resource holding it (name + +uuid, and the compose service if it is held per-service), and **whether that resource +is inside the applied project or outside it**. Outside is the usual case, and it has a +usual cause worth naming: *residue from an earlier run cleaned up by deleting a +Coolify project.* Deleting a project does **not** delete its resources — they survive, +invisible to cast, still holding the domain instance-wide. (The scope claim is checked +against the live resources cast read, never assumed: a conflict with something in the +plan's own project — a renamed resource — is a different fix, and would be a lie +otherwise.) + +**cast never sends `force_domain_override=true`.** Coolify offers it in the error text +and it is the wrong answer: two resources on one domain is a routing coin-flip, and +Coolify says so in the same response (*"can cause routing conflicts and unpredictable +behavior"*). Nothing in cast can send that flag, and no retry may ever set it — if it +is ever wanted, it is an explicit operator act in the UI, not a tool's decision. + ## Instance selection **The Coolify a command talks to is an explicit, named value** — not a property diff --git a/src/cli.ts b/src/cli.ts index 04505d7..87b15ca 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -800,6 +800,28 @@ async function runProject( console.log(renderDiff(report)); if (ctx.command === "diff") return report.clean ? { status: "clean" } : { status: "drift" }; + // Before ANYTHING is written — the project and the environment are created lazily + // by the first create, so this is the last moment at which a refusal still costs + // nothing. A create whose domains are already claimed elsewhere on the instance + // will be refused by Coolify no matter what cast does (#44); the only question is + // whether the operator learns it now or half-way through an apply that has already + // built a project. One GET, and only on a plan that creates an application with a + // domain — a first apply, and nothing else. + const visibleUuids = new Set(live.map((l) => l.uuid)); + const domainConflicts = await preflightDomainConflicts( + ctx.client, + report.changes, + ); + if (domainConflicts.length > 0) { + throw new Error( + domainConflictRemedy(domainConflicts, { + project: projectName, + env: coolifyEnv, + visible: visibleUuids, + stage: "preflight", + }), + ); + } const serverUuid = await ctx.client.serverUuid(ctx.binding.server); const githubAppUuid = await ctx.client.githubAppUuid( githubAppNameFor(ctx.bindings, orgRepo), @@ -820,6 +842,7 @@ async function runProject( destinationUuid: projectBinding?.destination_uuid, s3DestinationUuid: ctx.binding.s3_destination, backupSchedules, + visibleUuids, }); const { mutated } = await applyPlan(report, desired, exec); console.log( @@ -1900,6 +1923,276 @@ export function serviceApiFields( return rest; } +// --- Domain uniqueness: instance-wide, while cast plans project-scoped (#44) --- +// +// Coolify enforces domain uniqueness across the whole instance (every application +// and every service application of the TEAM, plus the instance fqdn: +// bootstrap/helpers/domains.php@checkIfDomainIsAlreadyUsedViaAPI, v4.1.2). cast +// plans inside ONE project + ONE environment. So apply can produce a plan that is +// internally consistent, correct against everything cast can observe, and still be +// refused — by a resource cast cannot see, for a reason invisible from its scope: +// +// POST /applications/private-github-app → 409: +// {"message":"Domain conflicts detected. Use force_domain_override=true to proceed.", +// "conflicts":[{"domain":"http://api.89.167.19.110.sslip.io","resource_name":"core", +// "resource_uuid":"tqsmnzdde…","resource_type":"application", +// "service_name":"api","message":"Domain … is already in use …"}], +// "warning":"Using the same domain for multiple resources can cause routing +// conflicts and unpredictable behavior."} +// +// Same family as the multi-destination 400 above (#41) — an instance-wide constraint +// arriving mid-apply, after the project and the environment have been made. Unlike +// that one, this one CAN be pre-flighted (GET /applications is the same population +// Coolify checks against), so it is: see preflightDomainConflicts. The 409 handling +// stays regardless, because the pre-flight is a subset — Coolify also compares +// against service fqdns and the instance fqdn, which no list cast can read exposes. +// +// force_domain_override=true is the one thing cast will never do about any of this. +// Coolify offers it in the error text; two resources sharing a domain is a routing +// coin-flip, and Coolify says as much in the same response ("can cause routing +// conflicts and unpredictable behavior"). If cast ever gains the flag it is an +// explicit operator act, never a retry — nothing below may send it. + +export type DomainConflict = { + domain: string; + resource_name: string; + resource_uuid: string; + resource_type: string; + // The conflicting app's compose SERVICE, when it holds the domain per-service. + service_name?: string; + // Which resource in OUR plan wanted the domain. Not Coolify's field — cast's, + // so a refusal listing three conflicts says which create each one blocks. + wanted_by?: string; +}; + +// Coolify's comparison, exactly: strip ONE trailing slash, then compare the +// strings LITERALLY — scheme and all (domains.php ~L153-177). So `http://x` and +// `https://x` are different domains to Coolify, and cast must not be cleverer +// here than the thing it is predicting: normalizing to a bare host would make the +// pre-flight disagree with the server, in both directions (missed conflicts, and +// refusals Coolify would have allowed). +function nakedDomain(raw: string): string { + const d = raw.trim(); + return d.endsWith("/") ? d.slice(0, -1) : d; +} + +// The domains a planned CREATE would claim. Applications only, and that is not an +// oversight: databases have no domains, and cast's service creates drop `domains` +// on the wire entirely (serviceApiFields above), so an application create is the +// only way an apply can claim one. +export function desiredDomainsOfCreate( + change: Change, +): Array<{ domain: string; service?: string }> { + if (change.kind !== "application" || change.op !== "create") return []; + const fields = Object.fromEntries( + change.fieldDiffs.map((f) => [f.field, f.desired]), + ); + const out: Array<{ domain: string; service?: string }> = []; + const flat = fields.domains; + if (Array.isArray(flat)) { + for (const d of flat) + if (typeof d === "string" && d.length > 0) + out.push({ domain: nakedDomain(d) }); + } + const compose = fields.docker_compose_domains as + | Record + | undefined; + if (compose) { + for (const [service, urls] of Object.entries(compose)) + for (const d of urls ?? []) + if (typeof d === "string" && d.length > 0) + out.push({ domain: nakedDomain(d), service }); + } + return out; +} + +// The domains a LIVE application holds, read off a raw GET /applications record. +// +// Both shapes, and they are mutually exclusive on Coolify's side: a non-compose app +// carries `fqdn` (a comma-separated string), a dockercompose app carries per-service +// domains in `docker_compose_domains` (JSON, service -> {domain: "a,b"}). The +// build_pack gate on the second one is Coolify's, not a guess (domains.php L189: +// `$app->build_pack === 'dockercompose' && ! empty($app->docker_compose_domains)`) +// — and it is load-bearing in the strict direction: a nixpacks app carrying stale +// compose-domain JSON does NOT conflict, so cast must not refuse for one either. A +// pre-flight stricter than the server is a pre-flight that blocks correct applies. +export function liveApplicationDomains( + raw: Record, +): Array<{ domain: string; service?: string }> { + const out: Array<{ domain: string; service?: string }> = []; + const fqdn = raw.fqdn; + if (typeof fqdn === "string") + for (const d of fqdn.split(",").filter(Boolean)) + out.push({ domain: nakedDomain(d) }); + if (raw.build_pack === "dockercompose") { + const compose = parseDockerComposeDomains(raw.docker_compose_domains); + if (compose) + for (const [service, urls] of Object.entries(compose)) + for (const d of urls) out.push({ domain: nakedDomain(d), service }); + } + return out; +} + +// The pure half: what would Coolify refuse, given this plan and this instance? +export function findDomainConflicts( + creates: Change[], + liveApps: Array>, +): DomainConflict[] { + const conflicts: DomainConflict[] = []; + for (const change of creates) { + for (const want of desiredDomainsOfCreate(change)) { + for (const app of liveApps) { + for (const held of liveApplicationDomains(app)) { + if (held.domain !== want.domain) continue; + conflicts.push({ + domain: want.domain, + resource_name: String(app.name ?? "(unnamed)"), + resource_uuid: String(app.uuid ?? "(unknown)"), + resource_type: "application", + ...(held.service ? { service_name: held.service } : {}), + wanted_by: `${change.kind} ${change.name}${want.service ? ` (service: ${want.service})` : ""}`, + }); + } + } + } + } + return conflicts; +} + +// N+1 was the obvious shape for this and it is not needed: GET /applications is +// serialized by the same removeSensitiveData() as GET /applications/{uuid}, so the +// list already carries `fqdn` and `docker_compose_domains` (see coolify.ts). One +// call, and only on a plan that creates an application with a domain — which is a +// first apply, and nothing else. +export async function preflightDomainConflicts( + client: CoolifyClient, + changes: Change[], +): Promise { + const creates = changes.filter( + (c) => c.op === "create" && desiredDomainsOfCreate(c).length > 0, + ); + if (creates.length === 0) return []; + return findDomainConflicts(creates, await client.applications()); +} + +// The 409, when one still gets through — an update that moves a domain, a conflict +// with a Coolify service or the instance fqdn (neither is in GET /applications), or +// a resource created between the pre-flight and the create. +function domainConflicts409(err: unknown): DomainConflict[] | undefined { + if (!(err instanceof HttpError) || err.status !== 409) return undefined; + // The HttpError message is "POST /path → 409: "; the body is the only part + // that carries the conflicts, and it is JSON. + const start = err.message.indexOf("{"); + if (start === -1) return undefined; + let body: unknown; + try { + body = JSON.parse(err.message.slice(start)); + } catch { + return undefined; + } + const parsed = body as { message?: unknown; conflicts?: unknown }; + // Narrow on the CONFLICTS, not on the status: 409 is also how Coolify answers a + // duplicate environment create (see ensureEnvironment), and this must not claim + // that one. + if (!Array.isArray(parsed.conflicts) || parsed.conflicts.length === 0) + return undefined; + return parsed.conflicts.map((c) => { + const e = c as Record; + return { + domain: String(e.domain ?? "(unknown)"), + resource_name: String(e.resource_name ?? "(unknown)"), + resource_uuid: String(e.resource_uuid ?? "(unknown)"), + resource_type: String(e.resource_type ?? "resource"), + ...(typeof e.service_name === "string" + ? { service_name: e.service_name } + : {}), + }; + }); +} + +// One renderer for both paths, because the operator's question is the same one +// whether cast refused before touching anything or Coolify refused mid-apply: what +// holds my domain, where is it, and why can't I see it? +export function domainConflictRemedy( + conflicts: DomainConflict[], + where: { + project: string; + env: string; + // The UUIDs of the live resources cast CAN see — this project, this + // environment. The whole point of the message is the scope claim, so the scope + // claim is checked rather than assumed: a conflict with something in the plan's + // own project (a renamed resource, say) is a different fix, and saying "outside + // your project" about it would be a lie. + visible: ReadonlySet; + // Before anything was mutated, or after. It decides what the operator is + // holding, which is the first thing they need to know. + stage: "preflight" | "apply"; + // Coolify's own words, kept verbatim when we have them — a translation that + // hides the original makes the next person's search fail. + coolify?: string; + }, +): string { + const head = + where.stage === "preflight" + ? `refusing to apply: ${conflicts.length === 1 ? "a domain in this plan is" : `${conflicts.length} domains in this plan are`} already claimed on this Coolify.` + : `create rejected: Coolify refused ${conflicts.length === 1 ? "a domain" : "domains"} in this plan as already claimed.`; + const lines = [ + head, + "", + "Domain uniqueness is enforced across the WHOLE Coolify instance. cast plans inside", + `one project + one environment (${where.project} / ${where.env}), so a plan can be`, + "correct against everything cast can see and still be refused by something it cannot.", + "", + ]; + for (const c of conflicts) { + const held = c.service_name + ? `${c.resource_type} '${c.resource_name}' (service: ${c.service_name})` + : `${c.resource_type} '${c.resource_name}'`; + lines.push(` ${c.domain}`); + if (c.wanted_by) lines.push(` wanted by: ${c.wanted_by}`); + lines.push(` claimed by: ${held}, uuid ${c.resource_uuid}`); + if (where.visible.has(c.resource_uuid)) { + lines.push( + ` It IS in ${where.project} / ${where.env} — under another name, so the plan does not`, + " match it to anything and wants to create beside it. Rename, or free the domain;", + " cast never deletes what it did not plan.", + ); + } else { + lines.push( + ` NOT in ${where.project} / ${where.env} — cast can neither see nor manage it.`, + " Most likely residue from an earlier run cleaned up by deleting a Coolify", + " project: deleting a project does NOT delete its resources. They survive it,", + " invisible to cast (no project it queries holds them), still owning the domain", + " instance-wide. Find it by uuid in the Coolify UI.", + ); + } + lines.push(""); + } + if (where.coolify) lines.push(` Coolify said: ${where.coolify}`, ""); + lines.push( + "Two fixes, and cast will take neither by itself: delete the resource that holds the", + "domain, or give this one a different domain (the manifest, or --hostname-overlay).", + "", + "cast will NOT retry with force_domain_override=true — the flag Coolify's own message", + "suggests. Two resources on one domain is a routing coin-flip, and the same response", + 'says so: "can cause routing conflicts and unpredictable behavior". If that is ever', + "what you want it is an operator act, never something a tool does on your behalf.", + ); + if (where.stage === "preflight") { + lines.push( + "", + "Nothing was created: this ran before the first write, so the refusal costs nothing.", + ); + } else { + lines.push( + "", + "This arrived mid-apply — the project and its environment may already exist. Re-run", + "once the conflict is gone: apply reads before it writes, and adopts them.", + ); + } + return lines.join("\n"); +} + // Coolify's answer when a server has more than one destination and the create did // not say which one to use (all three controllers, identically, v4.1.2): // @@ -1977,6 +2270,14 @@ export function buildExecutor( destinationUuid?: string; s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names backupSchedules: Record; + // The live resources of THIS project + environment, by uuid — everything cast + // can see. Read by exactly one thing: the domain-conflict message, which has to + // say whether the resource holding the domain is inside the applied project or + // outside it, and must not guess (see domainConflictRemedy). Optional because an + // executor built without it is not wrong, only less able to place a conflict: + // an empty set says "cast sees nothing here", which is what a caller that did + // not read the project is in fact claiming. + visibleUuids?: ReadonlySet; }, ): Executor { // Coolify resolves this identically for applications, databases and services @@ -2006,17 +2307,45 @@ export function buildExecutor( ctx.projectName, ctx.envName, ); - // Wrapped around all three creates rather than around each one: Coolify runs - // the same destination logic in ApplicationsController, DatabasesController and + // The two instance-wide constraints a create can die on, both of them invisible + // from cast's project-scoped view, both of them arriving at the FIRST create — + // after apply has already made the project and the environment. One wrapper, and + // wrapped around all three creates rather than around each one: Coolify runs the + // same destination logic in ApplicationsController, DatabasesController and // ServicesController, so whichever kind happens to be created first is the one // that 400s, and which one that is depends only on the order of the manifest. - const withDestinationDiagnosis = async ( + const withCreateDiagnosis = async ( change: Change, create: () => Promise, ): Promise => { try { return await create(); } catch (err) { + // The domain-conflict 409 the pre-flight could not have caught: a conflict + // with a Coolify SERVICE or with the instance fqdn (neither appears in + // GET /applications, so preflightDomainConflicts is a strict subset of + // Coolify's own check), or a resource created between the pre-flight and this + // create. Rare by construction — and precisely because it is rare, it must not + // be the one that arrives untranslated. (#44) + const conflicts = domainConflicts409(err); + if (conflicts) { + throw new Error( + domainConflictRemedy( + conflicts.map((c) => ({ + ...c, + wanted_by: `${change.kind} ${change.name}`, + })), + { + project: ctx.projectName, + env: ctx.envName, + visible: ctx.visibleUuids ?? new Set(), + stage: "apply", + coolify: err instanceof Error ? err.message : String(err), + }, + ), + { cause: err }, + ); + } if (!isMultiDestination400(err)) throw err; throw new Error( multiDestinationRemedy({ @@ -2032,7 +2361,7 @@ export function buildExecutor( }; return { async createResource(change) { - return withDestinationDiagnosis(change, async () => { + return withCreateDiagnosis(change, async () => { // Field payloads assembled from change.fieldDiffs (desired values): const fields = Object.fromEntries( change.fieldDiffs.map((f) => [f.field, f.desired]), diff --git a/src/coolify.ts b/src/coolify.ts index cae1bf0..5dadcb4 100644 --- a/src/coolify.ts +++ b/src/coolify.ts @@ -119,6 +119,32 @@ export class CoolifyClient { }>; } + // Every application the TOKEN can see, raw — the whole instance, not one + // project. The population a create's domains are checked against, and the + // only way cast can read it (see the domain pre-flight in cli.ts, #44). + // + // Two scopes have to be the same one for a pre-flight to mean anything, and + // they are: ApplicationsController@applications lists + // `Application::ownedByCurrentTeamAPI($teamId)`, and the create-time conflict + // check (bootstrap/helpers/domains.php@checkIfDomainIsAlreadyUsedViaAPI, + // v4.1.2) walks that same set. What it ALSO walks and this does not: + // ServiceApplication fqdns and the instance-level fqdn. So this list is a + // subset of what Coolify checks — which is why the 409 translation stays, + // and is not dead code once the pre-flight exists. + // + // Raw records rather than a narrow type, because the useful fields are not + // documented anywhere cast could import from: the vendored OpenAPI does not + // even list `fqdn` here. The list is serialized by the SAME + // removeSensitiveData() the per-application GET uses (ApplicationsController + // :38, called at :130 and :1980 @ v4.1.2), so it carries every non-sensitive + // column — `fqdn`, `docker_compose_domains`, `build_pack`, `uuid`, `name` — + // and a per-app GET would return byte-for-byte the same fields. Reading them + // is the caller's job (cli.ts@liveApplicationDomains). + async applications(): Promise>> { + const raw = await this.get("/applications"); + return Array.isArray(raw) ? (raw as Array>) : []; + } + // A project's environment NAMES. // // Two roads, because the vendored OpenAPI has been wrong before and this is a diff --git a/test/domain-preflight.test.ts b/test/domain-preflight.test.ts new file mode 100644 index 0000000..b05a633 --- /dev/null +++ b/test/domain-preflight.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildExecutor, + desiredDomainsOfCreate, + domainConflictRemedy, + findDomainConflicts, + liveApplicationDomains, + preflightDomainConflicts, +} from "../src/cli.js"; +import { CoolifyClient } from "../src/coolify.js"; +import type { Change } from "../src/diff.js"; + +// #44: Coolify enforces domain uniqueness across the WHOLE instance +// (bootstrap/helpers/domains.php@checkIfDomainIsAlreadyUsedViaAPI, v4.1.2); cast +// plans inside one project + one environment. So a plan can be correct against +// everything cast can observe and still be refused — by a resource cast cannot see, +// with a raw 409, mid-apply, after the project and the environment have been made. +// +// Three things are under test here, and none of them is the happy path: the plan +// that gets REFUSED before it writes, the 409 that gets TRANSLATED when one still +// arrives, and the flag cast must never send to make either of them go away. + +const create = (name: string, fields: Record): Change => ({ + kind: "application", + name, + op: "create", + fieldDiffs: Object.entries(fields).map(([field, desired]) => ({ + field, + desired, + updatable: true, + })), + envDiffs: [], +}); + +// A live non-compose application, as GET /applications actually serializes one: +// `fqdn` is a comma-separated string. (The vendored OpenAPI does not document the +// field at all — it is there because ApplicationsController@applications runs the +// same removeSensitiveData() as the per-app GET, and hides neither.) +const liveApp = (name: string, uuid: string, fqdn: string | null) => ({ + uuid, + name, + build_pack: "nixpacks", + fqdn, +}); + +// A live dockercompose application: domains live per-service, JSON-encoded. +const liveComposeApp = ( + name: string, + uuid: string, + services: Record, +) => ({ + uuid, + name, + build_pack: "dockercompose", + fqdn: null, + docker_compose_domains: JSON.stringify( + Object.entries(services).map(([n, domain]) => ({ name: n, domain })), + ), +}); + +describe("reading the domains of a plan and of an instance", () => { + it("takes an application create's flat domains and its per-service ones", () => { + expect( + desiredDomainsOfCreate( + create("core", { domains: ["https://a.example.com"] }), + ), + ).toEqual([{ domain: "https://a.example.com" }]); + expect( + desiredDomainsOfCreate( + create("core", { + build_pack: "dockercompose", + docker_compose_domains: { + api: ["https://api.example.com", "https://alt.example.com"], + web: ["https://web.example.com"], + }, + }), + ), + ).toEqual([ + { domain: "https://api.example.com", service: "api" }, + { domain: "https://alt.example.com", service: "api" }, + { domain: "https://web.example.com", service: "web" }, + ]); + }); + + // Databases have none, and cast's service creates drop `domains` on the wire + // (serviceApiFields) — an application create is the only way an apply claims one. + it("claims nothing for a database or service create, or for an update", () => { + const db: Change = { + kind: "database", + name: "postgres", + op: "create", + fieldDiffs: [{ field: "type", desired: "postgresql", updatable: false }], + envDiffs: [], + }; + const update: Change = { + ...create("core", { domains: ["https://a.example.com"] }), + op: "update", + uuid: "live-1", + }; + expect(desiredDomainsOfCreate(db)).toEqual([]); + expect(desiredDomainsOfCreate(update)).toEqual([]); + }); + + it("reads both live shapes: fqdn, and per-service compose domains", () => { + expect( + liveApplicationDomains( + liveApp("core", "u1", "https://a.example.com,https://b.example.com"), + ), + ).toEqual([ + { domain: "https://a.example.com" }, + { domain: "https://b.example.com" }, + ]); + expect( + liveApplicationDomains( + liveComposeApp("core", "u1", { api: "https://api.example.com" }), + ), + ).toEqual([{ domain: "https://api.example.com", service: "api" }]); + }); +}); + +describe("finding what Coolify would refuse", () => { + it("catches a plain fqdn conflict and names the resource holding it", () => { + const conflicts = findDomainConflicts( + [create("core", { domains: ["https://api.example.com"] })], + [ + liveApp("unrelated", "u1", "https://other.example.com"), + liveApp("core", "tqsmnzdde6oxz3fhl63e2xvl", "https://api.example.com"), + ], + ); + expect(conflicts).toEqual([ + { + domain: "https://api.example.com", + resource_name: "core", + resource_uuid: "tqsmnzdde6oxz3fhl63e2xvl", + resource_type: "application", + wanted_by: "application core", + }, + ]); + }); + + // The shape the real incident had: both sides dockercompose, the domain held by a + // SERVICE of an app in a project cast never queries. + it("catches a per-service compose conflict, on both sides", () => { + const conflicts = findDomainConflicts( + [ + create("core", { + build_pack: "dockercompose", + docker_compose_domains: { + api: ["http://api.89.167.19.110.sslip.io"], + }, + }), + ], + [ + liveComposeApp("core", "tqsmnzdde6oxz3fhl63e2xvl", { + api: "http://api.89.167.19.110.sslip.io", + web: "http://89.167.19.110.sslip.io", + }), + ], + ); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + domain: "http://api.89.167.19.110.sslip.io", + resource_uuid: "tqsmnzdde6oxz3fhl63e2xvl", + service_name: "api", + wanted_by: "application core (service: api)", + }); + }); + + it("strips one trailing slash on both sides, exactly as Coolify does", () => { + expect( + findDomainConflicts( + [create("core", { domains: ["https://api.example.com/"] })], + [liveApp("ghost", "u1", "https://api.example.com")], + ), + ).toHaveLength(1); + expect( + findDomainConflicts( + [create("core", { domains: ["https://api.example.com"] })], + [liveApp("ghost", "u1", "https://api.example.com/")], + ), + ).toHaveLength(1); + }); + + // Coolify compares the strings LITERALLY, scheme included (domains.php ~L153-177). + // A pre-flight cleverer than the server it predicts is a pre-flight that disagrees + // with it — here, by refusing an apply Coolify would have allowed. + it("does not treat http:// and https:// as the same domain", () => { + expect( + findDomainConflicts( + [create("core", { domains: ["https://api.example.com"] })], + [liveApp("ghost", "u1", "http://api.example.com")], + ), + ).toEqual([]); + }); + + // domains.php L189 gates the compose check on build_pack === 'dockercompose'. + // A nixpacks app carrying stale compose-domain JSON does NOT conflict for Coolify, + // so it must not conflict for cast either — a false refusal blocks a correct apply. + it("ignores compose domains on an app whose build_pack is not dockercompose", () => { + expect( + findDomainConflicts( + [create("core", { domains: ["https://api.example.com"] })], + [ + { + ...liveApp("stale", "u1", null), + docker_compose_domains: JSON.stringify([ + { name: "api", domain: "https://api.example.com" }, + ]), + }, + ], + ), + ).toEqual([]); + }); + + it("is silent when nothing is claimed", () => { + expect( + findDomainConflicts( + [create("core", { domains: ["https://api.example.com"] })], + [liveApp("other", "u1", "https://elsewhere.example.com")], + ), + ).toEqual([]); + }); +}); + +describe("preflightDomainConflicts", () => { + const instance = (apps: unknown[]) => { + const fetchImpl = vi.fn(async (url: string | URL) => { + const path = new URL(String(url)).pathname; + if (path === "/api/v1/applications") + return new Response(JSON.stringify(apps), { status: 200 }); + throw new Error(`unexpected request: ${path}`); + }) as unknown as typeof fetch; + return { + client: new CoolifyClient("https://coolify.test", "tok", fetchImpl), + fetchImpl: fetchImpl as unknown as ReturnType, + }; + }; + + it("reads the whole instance once and reports the conflict", async () => { + const { client, fetchImpl } = instance([ + liveApp("core", "tqsmnzdde6oxz3fhl63e2xvl", "https://api.example.com"), + ]); + const conflicts = await preflightDomainConflicts(client, [ + create("core", { domains: ["https://api.example.com"] }), + ]); + expect(conflicts).toHaveLength(1); + // One call. GET /applications carries fqdn and docker_compose_domains already + // (same serializer as the per-app GET), so the N+1 the issue expected is not + // needed — and a per-app GET would return byte-for-byte the same fields. + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + // The cost has to be nothing on the runs that are not first applies — which is + // every run but one, forever. + it("touches Coolify at all only when the plan creates an application with a domain", async () => { + const { client, fetchImpl } = instance([]); + const update: Change = { + ...create("core", { domains: ["https://api.example.com"] }), + op: "update", + uuid: "live-1", + }; + expect(await preflightDomainConflicts(client, [update])).toEqual([]); + expect( + await preflightDomainConflicts(client, [ + create("core", { build_pack: "nixpacks" }), + ]), + ).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe("the refusal an operator actually reads", () => { + const conflict = { + domain: "http://api.89.167.19.110.sslip.io", + resource_name: "core", + resource_uuid: "tqsmnzdde6oxz3fhl63e2xvl", + resource_type: "application", + service_name: "api", + wanted_by: "application core (service: api)", + }; + + it("names the resource, its uuid, and that it is OUTSIDE the applied project", () => { + const message = domainConflictRemedy([conflict], { + project: "incubator", + env: "production", + visible: new Set(["some-app-in-this-project"]), + stage: "preflight", + }); + expect(message).toContain("http://api.89.167.19.110.sslip.io"); + expect(message).toContain("application 'core' (service: api)"); + expect(message).toContain("uuid tqsmnzdde6oxz3fhl63e2xvl"); + // The part the operator cannot work out from Coolify's own message. + expect(message).toContain("NOT in incubator / production"); + expect(message).toContain("cast can neither see nor manage it"); + // And where it came from, which is the part that stops it happening again. + expect(message).toMatch( + /deleting a project does NOT delete its resources/i, + ); + // Refused before the first write. + expect(message).toContain("Nothing was created"); + }); + + // The scope claim is the whole message, so it is checked rather than assumed: a + // conflict with something in the plan's OWN project is a different fix, and "cast + // cannot see it" would be a lie about a resource sitting in the diff. + it("says the opposite when the conflicting resource IS in this project", () => { + const message = domainConflictRemedy([conflict], { + project: "incubator", + env: "production", + visible: new Set(["tqsmnzdde6oxz3fhl63e2xvl"]), + stage: "preflight", + }); + expect(message).toContain("It IS in incubator / production"); + expect(message).not.toContain("cast can neither see nor manage it"); + }); + + it("refuses force_domain_override in the same breath as naming it", () => { + const message = domainConflictRemedy([conflict], { + project: "incubator", + env: "production", + visible: new Set(), + stage: "preflight", + }); + expect(message).toContain( + "cast will NOT retry with force_domain_override=true", + ); + expect(message).toContain("routing conflicts and unpredictable behavior"); + }); +}); + +// The 409 that gets through anyway: Coolify also checks service fqdns and the +// instance fqdn, neither of which GET /applications lists. The pre-flight is a +// subset of Coolify's check, so the translation is not dead code. +describe("buildExecutor createResource (domain-conflict 409, #44)", () => { + const app = create("core", { + build_pack: "dockercompose", + docker_compose_domains: { api: ["http://api.89.167.19.110.sslip.io"] }, + }); + + // Coolify's real answer, verbatim (ApplicationsController L1112-1127 @ v4.1.2). + const conflict409 = JSON.stringify({ + message: + "Domain conflicts detected. Use force_domain_override=true to proceed.", + conflicts: [ + { + domain: "http://api.89.167.19.110.sslip.io", + resource_name: "core", + resource_uuid: "tqsmnzdde6oxz3fhl63e2xvl", + resource_type: "application", + service_name: "api", + message: + "Domain http://api.89.167.19.110.sslip.io is already in use by application 'core' (service: api)", + }, + ], + warning: + "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.", + }); + + const coolify = (createResponse: Response | (() => Response)) => { + const bodies: unknown[] = []; + const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + const method = init?.method ?? "GET"; + if (init?.body) bodies.push(JSON.parse(String(init.body))); + if (path === "/api/v1/projects" && method === "GET") + return new Response( + JSON.stringify([{ uuid: "proj-1", name: "incubator" }]), + { status: 200 }, + ); + if (path === "/api/v1/projects/proj-1/environments" && method === "GET") + return new Response(JSON.stringify([{ name: "production" }]), { + status: 200, + }); + return typeof createResponse === "function" + ? createResponse() + : createResponse.clone(); + }) as unknown as typeof fetch; + return { fetchImpl, bodies }; + }; + + const exec = (fetchImpl: typeof fetch) => + buildExecutor(new CoolifyClient("https://coolify.test", "tok", fetchImpl), { + projectName: "incubator", + envName: "production", + serverUuid: "srv-1", + githubAppUuid: "gh-1", + serverName: "prod-box", + orgRepo: "heavy-duty/incubator", + bindingEnv: "production", + backupSchedules: {}, + visibleUuids: new Set(["an-app-cast-can-see"]), + }); + + it("translates it into the scope fact Coolify never states", async () => { + const { fetchImpl, bodies } = coolify( + new Response(conflict409, { status: 409 }), + ); + const err = await exec(fetchImpl) + .createResource(app) + .catch((e: Error) => e); + + expect(err).toBeInstanceOf(Error); + const message = (err as Error).message; + expect(message).toContain("Coolify refused"); + expect(message).toContain("uuid tqsmnzdde6oxz3fhl63e2xvl"); + expect(message).toContain("NOT in incubator / production"); + expect(message).toMatch( + /deleting a project does NOT delete its resources/i, + ); + // Mid-apply: the project and the environment are already there. The operator + // needs to know the re-run is safe, which is a different sentence from the + // pre-flight's "nothing was created". + expect(message).toContain("arrived mid-apply"); + // Coolify's own words survive the translation. + expect(message).toContain("Domain conflicts detected"); + // The suggestion in those words is answered, not followed. + expect(message).toContain( + "cast will NOT retry with force_domain_override=true", + ); + // And nothing was retried: one create attempt, and not one request body in the + // whole exchange carried the flag. + expect(bodies).toHaveLength(1); + for (const body of bodies) + expect(body).not.toHaveProperty("force_domain_override"); + }); + + // 409 is also how Coolify answers a duplicate environment create (ensureEnvironment + // swallows exactly that). Narrowing on the status alone would make this translation + // claim any of them. + it("leaves a 409 that carries no conflicts exactly as Coolify sent it", async () => { + const { fetchImpl } = coolify( + new Response(JSON.stringify({ message: "Already exists." }), { + status: 409, + }), + ); + const err = await exec(fetchImpl) + .createResource(app) + .catch((e: Error) => e); + + expect((err as Error).message).toContain( + '409: {"message":"Already exists."}', + ); + expect((err as Error).message).not.toContain("force_domain_override"); + }); +});