fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44)

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 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-14 22:30:34 +00:00
parent 7cc3a3b64e
commit 2e201fb58a
4 changed files with 848 additions and 4 deletions

View file

@ -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

View file

@ -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<string, string[]>
| 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<string, unknown>,
): 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<Record<string, unknown>>,
): 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<DomainConflict[]> {
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: <body>"; 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<string, unknown>;
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<string>;
// 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<string, { frequency: string; retention: number }>;
// 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<string>;
},
): 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<string>,
): Promise<string> => {
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]),

View file

@ -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<Array<Record<string, unknown>>> {
const raw = await this.get("/applications");
return Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
}
// A project's environment NAMES.
//
// Two roads, because the vendored OpenAPI has been wrong before and this is a

View file

@ -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<string, unknown>): 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<string, string>,
) => ({
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<typeof vi.fn>,
};
};
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");
});
});