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>
1442 lines
54 KiB
TypeScript
1442 lines
54 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applicationApiFields,
|
|
buildExecutor,
|
|
databaseApiFields,
|
|
databaseVersionFromImage,
|
|
defaultDatabaseImage,
|
|
parseDockerComposeDomains,
|
|
projectLiveFields,
|
|
serviceApiFields,
|
|
} from "../src/cli.js";
|
|
import { CoolifyClient } from "../src/coolify.js";
|
|
import { computeDiff } from "../src/diff.js";
|
|
import { DERIVED_UNRESOLVED } from "../src/envtemplate.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", () => {
|
|
// cast#72: a service's per-container hostnames go on the wire as `urls`
|
|
// ({name, url}[], url comma-joined) — matched to a ServiceApplication by name
|
|
// on create/PATCH. `service_domains` is the internal map that becomes it.
|
|
it("builds `urls` from service_domains", () => {
|
|
const out = serviceApiFields({
|
|
type: "plausible",
|
|
service_domains: {
|
|
web: ["https://stats.example.com", "https://alt.example.com"],
|
|
collector: ["https://collect.example.com"],
|
|
},
|
|
});
|
|
expect(out).toEqual({
|
|
type: "plausible",
|
|
urls: [
|
|
{
|
|
name: "web",
|
|
url: "https://stats.example.com,https://alt.example.com",
|
|
},
|
|
{ name: "collector", url: "https://collect.example.com" },
|
|
],
|
|
});
|
|
expect(out).not.toHaveProperty("service_domains");
|
|
});
|
|
it("sends no `urls` for a service with no service_domains", () => {
|
|
const out = serviceApiFields({ type: "plausible" });
|
|
expect(out).toEqual({ type: "plausible" });
|
|
expect(out).not.toHaveProperty("urls");
|
|
});
|
|
});
|
|
|
|
describe("parseDockerComposeDomains", () => {
|
|
// cast#68: the REAL read shape on a live Coolify 4.1.2 — a service-keyed
|
|
// object, NOT the array the OpenAPI implies. This exact string came off the
|
|
// wire. It must decode into cast's internal { service: string[] } map.
|
|
it("parses the real service-keyed object shape", () => {
|
|
const raw =
|
|
'{"api":{"domain":"https://api.heavyduty.builders"},"admin":{"domain":"https://admin.heavyduty.builders"},"intake":{"domain":"https://apply.heavyduty.builders"}}';
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
api: ["https://api.heavyduty.builders"],
|
|
admin: ["https://admin.heavyduty.builders"],
|
|
intake: ["https://apply.heavyduty.builders"],
|
|
});
|
|
});
|
|
|
|
it("splits a comma-joined domain string into the internal array", () => {
|
|
const raw =
|
|
'{"api":{"domain":"https://a.example.com,https://b.example.com"}}';
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
api: ["https://a.example.com", "https://b.example.com"],
|
|
});
|
|
});
|
|
|
|
// The write side (applicationApiFields) still emits the array shape, and the
|
|
// vendored OpenAPI documents it — keep tolerating it so the round-trip holds.
|
|
it("still parses the legacy array-of-{name,domain} shape", () => {
|
|
const raw = '[{"name":"api","domain":"https://api.example.com"}]';
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
api: ["https://api.example.com"],
|
|
});
|
|
});
|
|
|
|
it("collapses a malformed string to undefined rather than throwing", () => {
|
|
expect(parseDockerComposeDomains("not json{")).toBeUndefined();
|
|
// A JSON scalar is neither shape.
|
|
expect(parseDockerComposeDomains("42")).toBeUndefined();
|
|
expect(parseDockerComposeDomains("")).toBeUndefined();
|
|
expect(parseDockerComposeDomains(null)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
// #63: the static-site fields must read back off a live application so they
|
|
// diff against the manifest — otherwise a UI flip of is_static is invisible.
|
|
it("reads is_static and the build/run commands off a live application", () => {
|
|
const out = projectLiveFields("application", {
|
|
git_repository: "org/repo",
|
|
git_branch: "main",
|
|
build_pack: "static",
|
|
base_directory: "/",
|
|
is_static: true,
|
|
install_command: "npm ci",
|
|
build_command: "npm run build -w apps/landing-site",
|
|
start_command: "node server.js",
|
|
});
|
|
expect(out.is_static).toBe(true);
|
|
expect(out.install_command).toBe("npm ci");
|
|
expect(out.build_command).toBe("npm run build -w apps/landing-site");
|
|
expect(out.start_command).toBe("node server.js");
|
|
});
|
|
|
|
it("reports a readable is_static as a boolean, tolerating Coolify's 1/0", () => {
|
|
expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe(
|
|
true,
|
|
);
|
|
expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe(
|
|
false,
|
|
);
|
|
// A real `false` is readable and projected as false, so a UI flip off
|
|
// `static` still diffs against a manifest that declares `static: true`.
|
|
expect(
|
|
projectLiveFields("application", { is_static: false }).is_static,
|
|
).toBe(false);
|
|
});
|
|
|
|
// cast#68: Coolify 4.1.2 returns is_static: null on the read path even for a
|
|
// genuinely-static app. An unreadable value must be OMITTED, not projected as
|
|
// `false` — projecting false diffed false→true and redeployed every run.
|
|
it("omits is_static when the live value is unreadable (null/absent)", () => {
|
|
expect(
|
|
projectLiveFields("application", { is_static: null }),
|
|
).not.toHaveProperty("is_static");
|
|
// Absent on the wire is the same unreadable case, not a real `false`.
|
|
expect(projectLiveFields("application", {})).not.toHaveProperty(
|
|
"is_static",
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// cast#68: the idempotency guarantee against the REAL live shape. When
|
|
// docker_compose_domains comes back as Coolify 4.1.2's service-keyed object
|
|
// string, a matching manifest must still produce ZERO field diff — before the
|
|
// fix the object bailed to undefined and cast diffed the map against nothing
|
|
// on every apply.
|
|
it("produces zero field diffs when live docker_compose_domains is the service-keyed object shape", () => {
|
|
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.heavyduty.builders"],
|
|
admin: ["https://admin.heavyduty.builders"],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const liveRaw = {
|
|
git_repository: "acme/widget",
|
|
git_branch: "main",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "docker-compose.yaml",
|
|
docker_compose_domains:
|
|
'{"api":{"domain":"https://api.heavyduty.builders"},"admin":{"domain":"https://admin.heavyduty.builders"}}',
|
|
};
|
|
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);
|
|
});
|
|
});
|
|
|
|
// cast#68: is_static is unreadable on Coolify 4.1.2's read path (returns null
|
|
// even for a static app). A manifest that declares `static: true` must NOT diff
|
|
// against that null forever — the comparison is skipped and a once-per-run warn
|
|
// is emitted. A real live boolean still diffs normally.
|
|
describe("is_static live-unreadable degradation (cast#68)", () => {
|
|
const baseFields = {
|
|
git_repository: "acme/site",
|
|
git_branch: "main",
|
|
build_pack: "static",
|
|
base_directory: "/",
|
|
};
|
|
|
|
it("does not diff is_static when the live value is unreadable, and warns once", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const desired = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "landing",
|
|
fields: { ...baseFields, is_static: true },
|
|
},
|
|
];
|
|
// projectLiveFields omits is_static from a null live read; fetchLive flags
|
|
// the resource staticNotCompared (mirrored here).
|
|
const liveFields = projectLiveFields("application", {
|
|
...baseFields,
|
|
is_static: null,
|
|
});
|
|
expect(liveFields).not.toHaveProperty("is_static");
|
|
const live = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "landing",
|
|
uuid: "app-uuid",
|
|
fields: liveFields,
|
|
staticNotCompared: true,
|
|
},
|
|
];
|
|
const report = computeDiff(desired, live, "structural");
|
|
const change = report.changes.find((c) => c.name === "landing");
|
|
expect(change?.fieldDiffs.some((f) => f.field === "is_static")).toBeFalsy();
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("still diffs is_static when the live value is a real boolean", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const desired = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "landing",
|
|
fields: { ...baseFields, is_static: true },
|
|
},
|
|
];
|
|
// A UI flip off static: the live value is a genuine `false`, readable and
|
|
// staticNotCompared unset — cast must catch the drift, not suppress it.
|
|
const live = [
|
|
{
|
|
kind: "application" as const,
|
|
name: "landing",
|
|
uuid: "app-uuid",
|
|
fields: projectLiveFields("application", {
|
|
...baseFields,
|
|
is_static: false,
|
|
}),
|
|
},
|
|
];
|
|
const report = computeDiff(desired, live, "structural");
|
|
const change = report.changes.find((c) => c.name === "landing");
|
|
const staticDiff = change?.fieldDiffs.find((f) => f.field === "is_static");
|
|
expect(staticDiff).toBeDefined();
|
|
expect(staticDiff?.desired).toBe(true);
|
|
expect(staticDiff?.live).toBe(false);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
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/projects/proj-1/environments")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
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",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
});
|
|
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/projects/proj-1/environments")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
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",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
});
|
|
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 },
|
|
);
|
|
// The environment a create names has to exist, so apply reads it first
|
|
// (#38). This project is an existing one and already carries `prod`.
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
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",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
destinationUuid: "dest-abc",
|
|
});
|
|
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",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
});
|
|
await exec.createResource(change);
|
|
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
|
},
|
|
);
|
|
});
|
|
|
|
// #38: the first apply against a project that does not exist yet. POST /projects
|
|
// hands the new project Coolify's OWN default environment ("production"), never
|
|
// ours — so a create that names `environment_name: prod` 404s with "Environment
|
|
// not found" and leaves the project behind, created and empty. Every environment
|
|
// cast had touched until then was hand-built in a UI and adopted, which is why
|
|
// the from-nothing path is the one that had never run.
|
|
describe("buildExecutor createResource (environment reconcile)", () => {
|
|
// A Coolify with ONE project's worth of state, driven by what the test hands
|
|
// it: `projects` is what GET /projects answers, `environments` what the
|
|
// project carries. Both mutate as cast writes, so the mock stays honest about
|
|
// what a second read would see.
|
|
function fakeCoolify(opts: {
|
|
projects?: Array<{ uuid: string; name: string }>;
|
|
environments?: string[];
|
|
envCreateStatus?: number;
|
|
// What an environment HOLDS, by name — the guard on #40's delete. A project
|
|
// cast just created cannot really hold anything, which is exactly why the
|
|
// mock has to be able to say otherwise: the guard is worth only as much as
|
|
// the case it refuses.
|
|
holds?: Record<string, unknown[]>;
|
|
envDeleteStatus?: number;
|
|
}) {
|
|
const projects = opts.projects ?? [];
|
|
let environments = opts.environments ?? [];
|
|
const holds = opts.holds ?? {};
|
|
const calls: string[] = [];
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
const method = init?.method ?? "GET";
|
|
calls.push(`${method} ${path}`);
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
return new Response(JSON.stringify(projects), { status: 200 });
|
|
if (path === "/api/v1/projects" && method === "POST") {
|
|
projects.push({ uuid: "proj-new", name: "widget" });
|
|
// Coolify's doing, not ours: a brand-new project comes with this.
|
|
environments = ["production"];
|
|
return new Response(JSON.stringify({ uuid: "proj-new" }), {
|
|
status: 201,
|
|
});
|
|
}
|
|
const envRoute = /^\/api\/v1\/projects\/([^/]+)\/environments$/.exec(
|
|
path,
|
|
);
|
|
if (envRoute && method === "GET")
|
|
return new Response(
|
|
JSON.stringify(environments.map((name) => ({ name }))),
|
|
{ status: 200 },
|
|
);
|
|
// CoolifyClient.environments falls back to the project show route when
|
|
// the list route answers empty — it carries the same names as a relation.
|
|
if (/^\/api\/v1\/projects\/[^/]+$/.test(path) && method === "GET")
|
|
return new Response(
|
|
JSON.stringify({
|
|
environments: environments.map((name) => ({ name })),
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
if (envRoute && method === "POST") {
|
|
const name = JSON.parse(String(init?.body)).name as string;
|
|
const status = opts.envCreateStatus ?? 201;
|
|
if (status === 409) {
|
|
// A 409 is Coolify saying the name is TAKEN — so in the world the
|
|
// mock is modelling it exists, created by whoever won the race
|
|
// between our read and our write. The environment is there; only our
|
|
// create lost. A 409 whose environment did not exist is not a state
|
|
// Coolify can be in, and pretending otherwise would test nothing.
|
|
if (!environments.includes(name)) environments.push(name);
|
|
return new Response(
|
|
JSON.stringify({
|
|
message: "Environment with this name already exists.",
|
|
}),
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
if (status !== 201)
|
|
return new Response(JSON.stringify({ message: "boom" }), { status });
|
|
environments.push(name);
|
|
return new Response(JSON.stringify({ uuid: "env-1" }), { status: 201 });
|
|
}
|
|
// DELETE /projects/{uuid}/environments/{name} — #40. Three segments, so the
|
|
// two-segment details route below cannot swallow it.
|
|
const envDelete =
|
|
/^\/api\/v1\/projects\/([^/]+)\/environments\/([^/]+)$/.exec(path);
|
|
if (envDelete && method === "DELETE") {
|
|
const status = opts.envDeleteStatus ?? 200;
|
|
if (status !== 200)
|
|
return new Response(JSON.stringify({ message: "boom" }), { status });
|
|
environments = environments.filter((e) => e !== envDelete[2]);
|
|
return new Response(
|
|
JSON.stringify({ message: "Environment deleted." }),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
// GET /projects/{uuid}/{environment} — the details route, the ONLY one that
|
|
// eager-loads an environment's resources, and so the only one that can
|
|
// answer "is it empty?". The list route's Environment objects carry no
|
|
// relations at all. Checked after the /environments routes above, which
|
|
// this pattern would otherwise match.
|
|
const envDetail = /^\/api\/v1\/projects\/([^/]+)\/([^/]+)$/.exec(path);
|
|
if (envDetail && method === "GET") {
|
|
const name = envDetail[2];
|
|
if (!environments.includes(name))
|
|
return new Response(JSON.stringify({ message: "Not found." }), {
|
|
status: 404,
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 1,
|
|
name,
|
|
project_id: 1,
|
|
description: "",
|
|
applications: holds[name] ?? [],
|
|
postgresqls: [],
|
|
redis: [],
|
|
services: [],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
// Coolify's actual rule, and the whole of #38: a create names an
|
|
// environment, and an environment that is not there is a 404. Without
|
|
// it this mock would happily accept the create that a real box refuses,
|
|
// and the test below would pass against the very bug it exists to catch.
|
|
const body = JSON.parse(String(init?.body)) as {
|
|
environment_name: string;
|
|
};
|
|
if (!environments.includes(body.environment_name))
|
|
return new Response(
|
|
JSON.stringify({ message: "Environment not found." }),
|
|
{ status: 404 },
|
|
);
|
|
return new Response(JSON.stringify({ uuid: "app-1" }), { status: 200 });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
return { calls, fetchImpl, environments: () => environments };
|
|
}
|
|
|
|
const app = {
|
|
kind: "application" as const,
|
|
name: "core",
|
|
op: "create" as const,
|
|
fieldDiffs: [
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
],
|
|
envDiffs: [],
|
|
};
|
|
|
|
// envName is a parameter because #40's third guard is about it: an environment
|
|
// named `production` is Coolify's leftover default in every run EXCEPT the one
|
|
// that asked for `--environment production`, where it is the environment
|
|
// everything is about to live in.
|
|
function exec(fetchImpl: typeof fetch, envName = "prod") {
|
|
return buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
{
|
|
projectName: "widget",
|
|
envName,
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
},
|
|
);
|
|
}
|
|
|
|
it("creates the environment on a project it just created, and the create names it", async () => {
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(uuid).toBe("app-1");
|
|
// The fix: our environment is created BEFORE the resource that names it.
|
|
expect(coolify.calls).toContain(
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
);
|
|
expect(coolify.environments()).toContain("prod");
|
|
const order = coolify.calls.indexOf(
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
);
|
|
const create = coolify.calls.indexOf(
|
|
"POST /api/v1/applications/private-github-app",
|
|
);
|
|
expect(order).toBeGreaterThan(-1);
|
|
expect(order).toBeLessThan(create);
|
|
});
|
|
|
|
// The half of idempotence that protects every apply that works today: an
|
|
// environment that already exists must not be written to at all.
|
|
it("never touches the create route when the environment already exists", async () => {
|
|
const coolify = fakeCoolify({
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
environments: ["prod", "staging"],
|
|
});
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(coolify.calls).toContain("GET /api/v1/projects/proj-1/environments");
|
|
expect(coolify.calls).not.toContain(
|
|
"POST /api/v1/projects/proj-1/environments",
|
|
);
|
|
});
|
|
|
|
// Coolify 409s a duplicate environment name — the same answer as "present",
|
|
// reached when something else wins the race between our read and our write.
|
|
it("treats a 409 from the environment create as already-there", async () => {
|
|
const coolify = fakeCoolify({
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
environments: [],
|
|
envCreateStatus: 409,
|
|
});
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
expect(uuid).toBe("app-1");
|
|
});
|
|
|
|
// A 5xx is NOT "already there" — apply must not go on to create resources
|
|
// into an environment it has no reason to believe exists.
|
|
it("surfaces a non-409 failure from the environment create", async () => {
|
|
const coolify = fakeCoolify({
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
environments: [],
|
|
envCreateStatus: 500,
|
|
});
|
|
await expect(exec(coolify.fetchImpl).createResource(app)).rejects.toThrow(
|
|
/environments → 500/,
|
|
);
|
|
});
|
|
|
|
// Five creates in a run must reconcile the project and its environment once,
|
|
// not five times.
|
|
it("reconciles once across several creates", async () => {
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
const e = exec(coolify.fetchImpl);
|
|
await e.createResource(app);
|
|
await e.createResource({ ...app, name: "core-api" });
|
|
|
|
const envReads = coolify.calls.filter((c) => c.endsWith("/environments"));
|
|
expect(envReads).toHaveLength(2); // one GET + one POST, not two of each
|
|
expect(
|
|
coolify.calls.filter((c) => c === "POST /api/v1/projects").length,
|
|
).toBe(1);
|
|
});
|
|
|
|
// #40: what #39 left behind. POST /projects hands the new project Coolify's own
|
|
// default environment, and #39 then created OURS beside it — so every project cast
|
|
// creates from nothing carried a permanently-empty `production` next to the
|
|
// environment everything actually lives in. That is the shape that makes a box
|
|
// unreadable later; the box being migrated away from has an empty `production` and
|
|
// runs everything in `staging`, and "the obvious guess is the wrong one" is a note
|
|
// we had to write for ourselves.
|
|
//
|
|
// This is also the ONE delete cast performs, so the guards are the test: it happens
|
|
// only to a project cast made in this run, only to an environment that is empty, and
|
|
// only when the name is not the one we asked for.
|
|
describe("buildExecutor createResource (default environment removal, #40)", () => {
|
|
const DELETE_DEFAULT =
|
|
"DELETE /api/v1/projects/proj-new/environments/production";
|
|
|
|
it("removes the empty default environment from a project it just created", async () => {
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(uuid).toBe("app-1");
|
|
expect(coolify.calls).toContain(DELETE_DEFAULT);
|
|
// The point of the whole issue: what is left is ours, and only ours.
|
|
expect(coolify.environments()).toEqual(["prod"]);
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
/removed Coolify's default environment production/,
|
|
);
|
|
notes.mockRestore();
|
|
});
|
|
|
|
// Guard 1. The rule cast does not get to break: a project someone built by hand
|
|
// is not cast's to tidy, whatever it happens to carry. `production` sitting empty
|
|
// next to `staging` on an ADOPTED project is exactly the live box we migrate from
|
|
// — and it stays untouched.
|
|
it("never removes an environment from a project it adopted", async () => {
|
|
const coolify = fakeCoolify({
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
environments: ["production", "prod"],
|
|
});
|
|
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
expect(coolify.environments()).toContain("production");
|
|
});
|
|
|
|
// Guard 2. Asked of Coolify, not inferred from "we just made this project". The
|
|
// mock is lying here — a fresh project cannot hold an application — and it must
|
|
// be able to, because a guard that is only ever handed the safe case is not a
|
|
// guard. cast declines, and says why.
|
|
it("leaves the default environment alone when it holds anything", async () => {
|
|
const coolify = fakeCoolify({
|
|
projects: [],
|
|
holds: { production: [{ uuid: "app-x", name: "legacy" }] },
|
|
});
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
expect(coolify.environments()).toContain("production");
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
/left Coolify's default environment production .* NOT empty/,
|
|
);
|
|
notes.mockRestore();
|
|
});
|
|
|
|
// Guard 3. `production` is a leftover in every run except the one that asked for
|
|
// it, where it is the environment everything is about to live in. Deleting it
|
|
// there would delete the target of the very apply doing the deleting.
|
|
it("keeps the default environment when it is the one we asked for", async () => {
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
|
|
await exec(coolify.fetchImpl, "production").createResource(app);
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
expect(coolify.environments()).toEqual(["production"]);
|
|
// ...and it was never re-created either: it was already there.
|
|
expect(coolify.calls).not.toContain(
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
);
|
|
});
|
|
|
|
// Tidying is a courtesy, and a courtesy that can fail an apply is not one. The
|
|
// resource still gets created; the operator is told what was left behind.
|
|
it("does not fail the apply when the delete fails", async () => {
|
|
const coolify = fakeCoolify({ projects: [], envDeleteStatus: 500 });
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
expect(uuid).toBe("app-1");
|
|
expect(coolify.environments()).toContain("production");
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
/could not remove it .*500/s,
|
|
);
|
|
notes.mockRestore();
|
|
});
|
|
});
|
|
});
|
|
|
|
// #41: a first apply against a server with more than one destination 400s on the
|
|
// first create — with the project and the environment already made. Coolify's own
|
|
// message names neither the remedy nor the file it goes in, and cast cannot
|
|
// pre-flight the condition (4.1.2 serves no destinations API at all, so a server's
|
|
// destination count is unknowable until a create has been attempted). The diagnosis
|
|
// is the whole of what is fixable, so the diagnosis is what is tested.
|
|
describe("buildExecutor createResource (multi-destination 400, #41)", () => {
|
|
const app = {
|
|
kind: "application" as const,
|
|
name: "core",
|
|
op: "create" as const,
|
|
fieldDiffs: [
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
],
|
|
envDiffs: [],
|
|
};
|
|
|
|
// Coolify's real answer, verbatim, from all three create controllers.
|
|
function multiDestinationCoolify() {
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
const method = init?.method ?? "GET";
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
status: 200,
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
message:
|
|
"Server has multiple destinations and you do not set destination_uuid.",
|
|
}),
|
|
{ status: 400 },
|
|
);
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
const exec = (fetchImpl: typeof fetch) =>
|
|
buildExecutor(new CoolifyClient("https://coolify.test", "tok", fetchImpl), {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
// The names the message has to be able to say back. None is on the wire:
|
|
// Coolify knows srv-1, the operator wrote prod-box.
|
|
serverName: "prod-box",
|
|
orgRepo: "heavy-duty/incubator",
|
|
bindingEnv: "prod",
|
|
});
|
|
|
|
const kinds = [
|
|
{ label: "application", change: app },
|
|
{
|
|
label: "database",
|
|
change: {
|
|
kind: "database" as const,
|
|
name: "postgres",
|
|
op: "create" as const,
|
|
fieldDiffs: [
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
],
|
|
envDiffs: [],
|
|
},
|
|
},
|
|
{
|
|
label: "service",
|
|
change: {
|
|
kind: "service" as const,
|
|
name: "umami",
|
|
op: "create" as const,
|
|
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
|
|
envDiffs: [],
|
|
},
|
|
},
|
|
];
|
|
|
|
// Every kind, because which one 400s first depends only on the order of the
|
|
// manifest — Coolify runs the same destination logic in all three controllers.
|
|
it.each(kinds)(
|
|
"answers the $label 400 with the state-file path the UUID goes in",
|
|
async ({ change }) => {
|
|
const err = await exec(multiDestinationCoolify())
|
|
.createResource(change)
|
|
.catch((e: Error) => e);
|
|
|
|
expect(err).toBeInstanceOf(Error);
|
|
const message = (err as Error).message;
|
|
// The remedy: the exact key, in the exact place, with the repo and the env
|
|
// the operator actually named.
|
|
expect(message).toContain(
|
|
"environments.prod.projects.heavy-duty/incubator.destination_uuid",
|
|
);
|
|
// The server, by the name the operator wrote — not srv-1.
|
|
expect(message).toContain("prod-box has multiple destinations");
|
|
// Which resource it died on, so a half-applied run can be read.
|
|
expect(message).toContain(`cannot create ${change.kind} ${change.name}`);
|
|
// Create-time: the part that decides whether they can fix this with an
|
|
// apply (they cannot) or a delete + recreate (they must).
|
|
expect(message).toMatch(/cannot be moved between networks later/);
|
|
// Coolify's own words survive — a translation that hides the original
|
|
// makes the next person's search fail.
|
|
expect(message).toContain(
|
|
"Server has multiple destinations and you do not set destination_uuid.",
|
|
);
|
|
},
|
|
);
|
|
|
|
// The translation must be about THIS 400, not about 400s. A create rejected for
|
|
// any other reason has to arrive unmolested, or the next bug gets a confident
|
|
// answer about a destination it has nothing to do with.
|
|
it("leaves every other failure exactly as Coolify sent it", async () => {
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
const method = init?.method ?? "GET";
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
status: 200,
|
|
});
|
|
return new Response(
|
|
JSON.stringify({ message: "The name field is required." }),
|
|
{ status: 422 },
|
|
);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const err = await exec(fetchImpl)
|
|
.createResource(app)
|
|
.catch((e: Error) => e);
|
|
|
|
expect((err as Error).message).toContain("The name field is required.");
|
|
expect((err as Error).message).not.toMatch(/destination/);
|
|
});
|
|
});
|
|
|
|
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
|
|
it("round-trips through defaultDatabaseImage for postgres", () => {
|
|
const image = defaultDatabaseImage("postgresql", "17");
|
|
expect(databaseVersionFromImage(image)).toBe("17");
|
|
});
|
|
});
|
|
|
|
// The write half of #51. Backup schedules live on their own route
|
|
// (/databases/{uuid}/backups), so `apply` has to read that route to know
|
|
// whether to POST or PATCH — and used to do neither outside the create branch.
|
|
describe("buildExecutor backup schedules", () => {
|
|
type Call = { method: string; path: string; body?: unknown };
|
|
|
|
// Records every request so a test can assert not just what was written, but
|
|
// what was NOT — a schedule silently skipped is the whole defect.
|
|
function recorder(handler: (path: string, init?: RequestInit) => Response): {
|
|
calls: Call[];
|
|
fetchImpl: typeof fetch;
|
|
} {
|
|
const calls: Call[] = [];
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
calls.push({
|
|
method: init?.method ?? "GET",
|
|
path,
|
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
});
|
|
return handler(path, init);
|
|
}) as unknown as typeof fetch;
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
const ctx = {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
s3DestinationUuid: "s3-1",
|
|
};
|
|
|
|
const schedule = { frequency: "0 3 * * *", retention: 7 };
|
|
const liveRow = {
|
|
uuid: "sched-1",
|
|
frequency: "0 5 * * *",
|
|
database_backup_retention_amount_locally: 3,
|
|
enabled: true,
|
|
};
|
|
|
|
// THE FIX. A database that exists and has no schedule gets one on update —
|
|
// before this, `apply` wrote a schedule only inside the create branch, so
|
|
// adding `backup:` to a live database was a clean run and zero backups.
|
|
it("CREATES the schedule on update when the database has none", async () => {
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "POST")
|
|
return new Response(JSON.stringify({ uuid: "sched-new" }), {
|
|
status: 201,
|
|
});
|
|
if (path === "/api/v1/databases/db-1/backups")
|
|
return new Response(JSON.stringify([]), { status: 200 }); // read: none
|
|
return new Response("{}", { status: 200 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
const post = calls.find((c) => c.method === "POST");
|
|
expect(post?.path).toBe("/api/v1/databases/db-1/backups");
|
|
expect(post?.body).toEqual({
|
|
frequency: "0 3 * * *",
|
|
database_backup_retention_amount_locally: 7,
|
|
save_s3: true,
|
|
s3_storage_uuid: "s3-1",
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("PATCHES the existing schedule rather than adding a second one", async () => {
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
|
return new Response("{}", { status: 200 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
|
const patch = calls.find((c) => c.method === "PATCH");
|
|
expect(patch?.path).toBe("/api/v1/databases/db-1/backups/sched-1");
|
|
expect(patch?.body).toMatchObject({
|
|
frequency: "0 3 * * *",
|
|
database_backup_retention_amount_locally: 7,
|
|
// Re-enabled: declaring `backup:` asks for backups, not for a disabled
|
|
// row that looks like backups.
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("does not PATCH the database itself for a backup-only change", async () => {
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
|
return new Response("{}", { status: 200 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
// `backup` is not a column on the database — it must never reach the
|
|
// database's own update body, and an empty body is not worth a write.
|
|
expect(calls.some((c) => c.path === "/api/v1/databases/db-1")).toBe(false);
|
|
});
|
|
|
|
// Degrade honestly: an apply that promised a backup schedule and cannot tell
|
|
// whether one already exists must STOP, not guess. POSTing blind would
|
|
// duplicate an existing schedule; skipping is the silent no-op being fixed.
|
|
it("refuses to guess when the schedule read fails", async () => {
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
return new Response("gateway timeout", { status: 504 });
|
|
return new Response("{}", { status: 200 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await expect(
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
).rejects.toThrow(/cannot set the declared backup schedule/);
|
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
|
});
|
|
|
|
it("refuses to guess which of several schedules the manifest meant", async () => {
|
|
const { fetchImpl } = recorder((path, init) => {
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
return new Response(
|
|
JSON.stringify([liveRow, { ...liveRow, uuid: "sched-2" }]),
|
|
{ status: 200 },
|
|
);
|
|
return new Response("{}", { status: 200 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await expect(
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
).rejects.toThrow(/holds 2 backup schedules/);
|
|
});
|
|
|
|
it("leaves an undeclared schedule alone (apply never removes)", async () => {
|
|
const { calls, fetchImpl } = recorder(
|
|
() => new Response("{}", { status: 200 }),
|
|
);
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.updateFields("db-1", "database", { version: "17" });
|
|
expect(calls.some((c) => c.path.includes("/backups"))).toBe(false);
|
|
});
|
|
|
|
it("still POSTs the schedule on create, without a read", async () => {
|
|
const { calls, fetchImpl } = recorder((path) => {
|
|
if (path === "/api/v1/projects")
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
status: 200,
|
|
});
|
|
if (path === "/api/v1/databases/postgresql")
|
|
return new Response(JSON.stringify({ uuid: "db-9" }), { status: 201 });
|
|
return new Response(JSON.stringify({ uuid: "sched-9" }), { status: 201 });
|
|
});
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.createResource({
|
|
kind: "database",
|
|
name: "postgres",
|
|
op: "create",
|
|
fieldDiffs: [
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
{ field: "backup", desired: schedule, updatable: true },
|
|
],
|
|
envDiffs: [],
|
|
});
|
|
// A database created a moment ago provably has no schedule: POST straight
|
|
// out, with no read that could fail and abort the create.
|
|
expect(
|
|
calls.some((c) => c.method === "GET" && c.path.includes("/backups")),
|
|
).toBe(false);
|
|
const post = calls.find((c) => c.path === "/api/v1/databases/db-9/backups");
|
|
expect(post?.body).toMatchObject({ frequency: "0 3 * * *", save_s3: true });
|
|
// And `backup` never reaches the database's own create body.
|
|
const create = calls.find((c) => c.path === "/api/v1/databases/postgresql");
|
|
expect(create?.body).not.toHaveProperty("backup");
|
|
});
|
|
|
|
it("refuses a declared schedule with no s3_destination configured", async () => {
|
|
const { fetchImpl } = recorder(
|
|
() => new Response(JSON.stringify([]), { status: 200 }),
|
|
);
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
{ ...ctx, s3DestinationUuid: undefined },
|
|
);
|
|
await expect(
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
).rejects.toThrow(/no s3_destination UUID/);
|
|
});
|
|
});
|
|
|
|
// The from-nothing half of derived resource URLs (#60): at plan time the
|
|
// database did not exist, so the app's ${resource:postgres.url} is still
|
|
// unresolved when apply goes to write its env. syncEnv reads the URL back from
|
|
// the environment_details route (the database was created earlier in the same
|
|
// apply) and writes the real value — or refuses, rather than writing a blank.
|
|
describe("buildExecutor syncEnv (derived resource URLs, #60)", () => {
|
|
const ctx = {
|
|
projectName: "widget",
|
|
envName: "prod",
|
|
serverUuid: "srv-1",
|
|
githubAppUuid: "gh-1",
|
|
serverName: "prod-box",
|
|
orgRepo: "acme/widget",
|
|
bindingEnv: "prod",
|
|
};
|
|
const derivedEnv = {
|
|
vars: {
|
|
DATABASE_URL: {
|
|
value: DERIVED_UNRESOLVED,
|
|
secret: true,
|
|
derived: { resource: "postgres", attr: "url" as const },
|
|
},
|
|
},
|
|
};
|
|
const URL_ = "postgres://u:p@pg-uuid:5432/widget";
|
|
|
|
// Serves /projects and the environment_details route; records the envs/bulk
|
|
// write so a test can assert what value landed (or that none did).
|
|
function box(internalDbUrl?: string) {
|
|
const bulkBodies: unknown[] = [];
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const path = new URL(String(url)).pathname;
|
|
if (path === "/api/v1/projects")
|
|
return new Response(
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/projects/proj-1/prod")
|
|
return new Response(
|
|
JSON.stringify({
|
|
postgresqls: [
|
|
internalDbUrl === undefined
|
|
? { name: "postgres" }
|
|
: { name: "postgres", internal_db_url: internalDbUrl },
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
if (path === "/api/v1/applications/app-1/envs/bulk") {
|
|
bulkBodies.push(JSON.parse(String(init?.body)));
|
|
return new Response(JSON.stringify([]), { status: 200 });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
return { fetchImpl, bulkBodies };
|
|
}
|
|
|
|
it("resolves the ref from the live database and writes the real URL", async () => {
|
|
const { fetchImpl, bulkBodies } = box(URL_);
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.syncEnv("app-1", "application", derivedEnv);
|
|
expect(bulkBodies).toHaveLength(1);
|
|
expect(bulkBodies[0]).toEqual({
|
|
data: [
|
|
{
|
|
key: "DATABASE_URL",
|
|
value: URL_,
|
|
is_buildtime: false,
|
|
is_preview: false,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("refuses — and writes nothing — when the database has no URL yet", async () => {
|
|
const { fetchImpl, bulkBodies } = box(undefined);
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await expect(
|
|
exec.syncEnv("app-1", "application", derivedEnv),
|
|
).rejects.toThrow(/cannot resolve derived env var/);
|
|
expect(bulkBodies).toHaveLength(0);
|
|
});
|
|
|
|
it("does not read the box at all when there is nothing derived to resolve", async () => {
|
|
const { fetchImpl, bulkBodies } = box(URL_);
|
|
const exec = buildExecutor(
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
ctx,
|
|
);
|
|
await exec.syncEnv("app-1", "application", {
|
|
vars: { PORT: { value: "3000", secret: false } },
|
|
});
|
|
// Straight to the write — no /projects lookup for a derived URL.
|
|
expect(bulkBodies).toHaveLength(1);
|
|
expect(
|
|
(
|
|
fetchImpl as unknown as { mock: { calls: unknown[][] } }
|
|
).mock.calls.some(([u]) => String(u).endsWith("/projects")),
|
|
).toBe(false);
|
|
});
|
|
});
|