A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applicationApiFields,
|
|
buildExecutor,
|
|
databaseApiFields,
|
|
databaseVersionFromImage,
|
|
defaultDatabaseImage,
|
|
projectLiveFields,
|
|
serviceApiFields,
|
|
} from "../src/cli.js";
|
|
import { CoolifyClient } from "../src/coolify.js";
|
|
import { computeDiff } from "../src/diff.js";
|
|
|
|
// Pure wire-translation helpers (Desired vocabulary <-> Coolify API
|
|
// vocabulary). Importing src/cli.ts here must not run the CLI — see the
|
|
// import.meta.url guard around main() at the bottom of that file.
|
|
|
|
describe("applicationApiFields", () => {
|
|
it("joins domains into a comma-separated string and renames port/healthcheck", () => {
|
|
const out = applicationApiFields({
|
|
port: 3000,
|
|
healthcheck: "/health",
|
|
domains: ["https://a.example.com", "https://b.example.com"],
|
|
});
|
|
expect(out).toEqual({
|
|
ports_exposes: "3000",
|
|
health_check_path: "/health",
|
|
domains: "https://a.example.com,https://b.example.com",
|
|
});
|
|
});
|
|
it("maps a docker_compose_domains map to the wire array-of-{name,domain} shape", () => {
|
|
const out = applicationApiFields({
|
|
docker_compose_domains: {
|
|
api: ["https://a", "https://b"],
|
|
admin: ["https://c"],
|
|
},
|
|
});
|
|
expect(out).toEqual({
|
|
docker_compose_domains: [
|
|
{ name: "api", domain: "https://a,https://b" },
|
|
{ name: "admin", domain: "https://c" },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("databaseApiFields", () => {
|
|
it("maps type+version to an image and drops the type/version keys", () => {
|
|
const out = databaseApiFields({ type: "postgresql", version: "17" });
|
|
expect(out).toEqual({ image: "postgres:17-alpine" });
|
|
expect(out).not.toHaveProperty("type");
|
|
expect(out).not.toHaveProperty("version");
|
|
});
|
|
});
|
|
|
|
describe("serviceApiFields", () => {
|
|
it("drops domains (services have no flat-domains create/update field)", () => {
|
|
const out = serviceApiFields({
|
|
type: "plausible",
|
|
domains: ["https://stats.example.com"],
|
|
});
|
|
expect(out).toEqual({ type: "plausible" });
|
|
expect(out).not.toHaveProperty("domains");
|
|
});
|
|
});
|
|
|
|
describe("projectLiveFields", () => {
|
|
it("projects a live application onto the Desired vocabulary", () => {
|
|
const out = projectLiveFields("application", {
|
|
git_repository: "org/repo",
|
|
git_branch: "main",
|
|
build_pack: "nixpacks",
|
|
base_directory: "/",
|
|
ports_exposes: "3000",
|
|
health_check_path: "/health",
|
|
fqdn: "https://a.example.com,https://b.example.com",
|
|
});
|
|
expect(out.domains).toEqual([
|
|
"https://a.example.com",
|
|
"https://b.example.com",
|
|
]);
|
|
expect(out.port).toBe(3000);
|
|
expect(out.healthcheck).toBe("/health");
|
|
});
|
|
|
|
it("normalizes a live database's database_type to the manifest vocabulary", () => {
|
|
const out = projectLiveFields("database", {
|
|
database_type: "standalone-postgresql",
|
|
image: "postgres:17-alpine",
|
|
});
|
|
expect(out).toEqual({ type: "postgresql", version: "17" });
|
|
});
|
|
|
|
it("projects both docker_compose_location and docker_compose_domains for a live compose app", () => {
|
|
const out = projectLiveFields("application", {
|
|
git_repository: "org/repo",
|
|
git_branch: "main",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "docker-compose.yaml",
|
|
docker_compose_domains: JSON.stringify([
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
]),
|
|
});
|
|
expect(out.docker_compose_location).toBe("docker-compose.yaml");
|
|
expect(out.docker_compose_domains).toEqual({
|
|
api: ["https://api.widget.example.com"],
|
|
});
|
|
});
|
|
|
|
it("does not choke on an absent/null docker_compose_domains", () => {
|
|
const out = projectLiveFields("application", {
|
|
git_repository: "org/repo",
|
|
git_branch: "main",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "docker-compose.yaml",
|
|
docker_compose_domains: null,
|
|
});
|
|
expect(out).not.toHaveProperty("docker_compose_domains");
|
|
});
|
|
});
|
|
|
|
describe("compose app idempotency (review finding #2)", () => {
|
|
it("produces zero field diffs when live docker_compose_location/domains match the manifest", () => {
|
|
const desired = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "core",
|
|
fields: {
|
|
git_repository: "acme/widget",
|
|
git_branch: "main",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "docker-compose.yaml",
|
|
docker_compose_domains: {
|
|
api: ["https://api.widget.example.com"],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const liveRaw = {
|
|
git_repository: "acme/widget",
|
|
git_branch: "main",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "docker-compose.yaml",
|
|
docker_compose_domains: JSON.stringify([
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
]),
|
|
};
|
|
const live = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "core",
|
|
uuid: "app-uuid",
|
|
fields: projectLiveFields("application", liveRaw),
|
|
},
|
|
];
|
|
const report = computeDiff(desired, live, "structural");
|
|
expect(report.clean).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("buildExecutor createResource (application, dockercompose)", () => {
|
|
function mockFetch(handler: (path: string, init?: RequestInit) => Response) {
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) =>
|
|
handler(new URL(String(url)).pathname, init),
|
|
) as unknown as typeof fetch;
|
|
}
|
|
|
|
it("sets connect_to_docker_network: true on a compose app create payload", async () => {
|
|
let createBody: Record<string, unknown> | undefined;
|
|
const fetchImpl = mockFetch((path, init) => {
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
createBody = JSON.parse(String(init?.body));
|
|
return new Response(JSON.stringify({ uuid: "app-1" }), {
|
|
status: 200,
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
const exec = buildExecutor(client, {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
backupSchedules: {},
|
|
});
|
|
const uuid = await exec.createResource({
|
|
kind: "application",
|
|
name: "core",
|
|
op: "create",
|
|
fieldDiffs: [
|
|
{ field: "build_pack", desired: "dockercompose", updatable: false },
|
|
{
|
|
field: "docker_compose_location",
|
|
desired: "docker-compose.yaml",
|
|
updatable: true,
|
|
},
|
|
{
|
|
field: "docker_compose_domains",
|
|
desired: { api: ["https://api.widget.example.com"] },
|
|
updatable: true,
|
|
},
|
|
],
|
|
envDiffs: [],
|
|
});
|
|
expect(uuid).toBe("app-1");
|
|
expect(createBody?.connect_to_docker_network).toBe(true);
|
|
expect(createBody?.docker_compose_domains).toEqual([
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
]);
|
|
});
|
|
|
|
it("does not set connect_to_docker_network for a non-compose app", async () => {
|
|
let createBody: Record<string, unknown> | undefined;
|
|
const fetchImpl = mockFetch((path, init) => {
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
createBody = JSON.parse(String(init?.body));
|
|
return new Response(JSON.stringify({ uuid: "app-2" }), {
|
|
status: 200,
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
});
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
const exec = buildExecutor(client, {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
backupSchedules: {},
|
|
});
|
|
await exec.createResource({
|
|
kind: "application",
|
|
name: "core-api",
|
|
op: "create",
|
|
fieldDiffs: [
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
{ field: "domains", desired: ["https://a"], updatable: true },
|
|
],
|
|
envDiffs: [],
|
|
});
|
|
expect(createBody).not.toHaveProperty("connect_to_docker_network");
|
|
});
|
|
});
|
|
|
|
// Placement is create-time only, and every kind needs it: Coolify runs the same
|
|
// destination logic in ApplicationsController, DatabasesController and
|
|
// ServicesController, and 400s on a multi-destination server for whichever one
|
|
// omits it. Missing it on the database create alone would be enough to leave a
|
|
// project's Postgres on the shared default network.
|
|
describe("buildExecutor createResource (destination placement)", () => {
|
|
function captureCreates() {
|
|
const bodies: Record<string, Record<string, unknown>> = {};
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (
|
|
path === "/api/v1/applications/private-github-app" ||
|
|
path === "/api/v1/databases/postgresql" ||
|
|
path === "/api/v1/services"
|
|
) {
|
|
bodies[path] = JSON.parse(String(init?.body));
|
|
return new Response(JSON.stringify({ uuid: "new-1" }), { status: 200 });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
return { bodies, fetchImpl };
|
|
}
|
|
|
|
const creates = [
|
|
{
|
|
path: "/api/v1/applications/private-github-app",
|
|
change: {
|
|
kind: "application" as const,
|
|
name: "core",
|
|
op: "create" as const,
|
|
fieldDiffs: [
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
{ field: "domains", desired: ["https://a"], updatable: true },
|
|
],
|
|
envDiffs: [],
|
|
},
|
|
},
|
|
{
|
|
path: "/api/v1/databases/postgresql",
|
|
change: {
|
|
kind: "database" as const,
|
|
name: "postgres",
|
|
op: "create" as const,
|
|
fieldDiffs: [
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
],
|
|
envDiffs: [],
|
|
},
|
|
},
|
|
{
|
|
path: "/api/v1/services",
|
|
change: {
|
|
kind: "service" as const,
|
|
name: "umami",
|
|
op: "create" as const,
|
|
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
|
|
envDiffs: [],
|
|
},
|
|
},
|
|
];
|
|
|
|
it.each(creates)(
|
|
"sends destination_uuid on the $path create",
|
|
async ({ path, change }) => {
|
|
const { bodies, fetchImpl } = captureCreates();
|
|
const client = new CoolifyClient(
|
|
"https://coolify.test",
|
|
"tok",
|
|
fetchImpl,
|
|
);
|
|
const exec = buildExecutor(client, {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
destinationUuid: "dest-abc",
|
|
backupSchedules: {},
|
|
});
|
|
await exec.createResource(change);
|
|
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
|
|
// The server still has to be named — a destination belongs to one.
|
|
expect(bodies[path]?.server_uuid).toBe("srv-1");
|
|
},
|
|
);
|
|
|
|
// Undeclared must mean ABSENT, not empty-string: Coolify branches on
|
|
// `$request->has('destination_uuid')`, so sending "" would take the
|
|
// "you gave me one" path and then fail to match any destination.
|
|
it.each(creates)(
|
|
"omits destination_uuid entirely when none is declared ($path)",
|
|
async ({ path, change }) => {
|
|
const { bodies, fetchImpl } = captureCreates();
|
|
const client = new CoolifyClient(
|
|
"https://coolify.test",
|
|
"tok",
|
|
fetchImpl,
|
|
);
|
|
const exec = buildExecutor(client, {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
backupSchedules: {},
|
|
});
|
|
await exec.createResource(change);
|
|
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
|
|
it("round-trips through defaultDatabaseImage for postgres", () => {
|
|
const image = defaultDatabaseImage("postgresql", "17");
|
|
expect(databaseVersionFromImage(image)).toBe("17");
|
|
});
|
|
});
|