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>
2026-07-14 22:30:34 +00:00
|
|
|
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" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
// Databases have no domains, and an update never claims (apply diffs the field
|
|
|
|
|
// and PATCHes it, it does not create). A SERVICE create does now claim — see
|
|
|
|
|
// the next test.
|
|
|
|
|
it("claims nothing for a database create or an update", () => {
|
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>
2026-07-14 22:30:34 +00:00
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
// cast#72: a service create sends `urls`, so its per-container service_domains
|
|
|
|
|
// must be pre-flighted — the more so because a service create whose domain
|
|
|
|
|
// conflicts is DELETED server-side before the 409 (applyServiceUrls rollback).
|
|
|
|
|
it("claims a service create's per-container service_domains", () => {
|
|
|
|
|
const svc: Change = {
|
|
|
|
|
kind: "service",
|
|
|
|
|
name: "umami",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "type", desired: "umami", updatable: false },
|
|
|
|
|
{
|
|
|
|
|
field: "service_domains",
|
|
|
|
|
desired: { umami: ["https://analytics.example.com"] },
|
|
|
|
|
updatable: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
};
|
|
|
|
|
expect(desiredDomainsOfCreate(svc)).toEqual([
|
|
|
|
|
{ domain: "https://analytics.example.com", service: "umami" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-07-14 22:30:34 +00:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|