cast/test/wire.test.ts

586 lines
21 KiB
TypeScript
Raw Normal View History

feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
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 },
);
fix: apply creates the environment its resources name (#38) POST /projects hands a new project Coolify's OWN default environment, `production` — never ours. cast then created every resource with `environment_name: <our --env>`, so the first apply against a project that did not exist yet 404'd on its first resource ("Environment not found") and left the project behind, created and empty. Two comments in the source already asserted the behaviour as though it were implemented (cli.ts:716, :808), and the README says it outright — the route existed in the vendored 4.1.2 spec, cast just never called it. It went unseen because every environment cast had touched until now was hand-built in a UI and adopted, so it already existed under whatever name someone typed. The genuinely-from-nothing apply is the one path nobody had run. apply now reconciles project + environment once per run, before the first create. Read-before-write: an environment that already exists is never written to, so adoption is untouched and this cannot regress an apply that works today. A 409 is read as "present" (the race between our read and our write). Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting it would be the first delete cast ever performs. An empty `production` beside the environment everything lives in is reported, the same courtesy an orphan gets, and removed by hand or not at all. The regression test drives the real failure, not a call count: the fake Coolify 404s a create whose environment_name it does not carry, exactly as a live box does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
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 },
);
fix: apply creates the environment its resources name (#38) POST /projects hands a new project Coolify's OWN default environment, `production` — never ours. cast then created every resource with `environment_name: <our --env>`, so the first apply against a project that did not exist yet 404'd on its first resource ("Environment not found") and left the project behind, created and empty. Two comments in the source already asserted the behaviour as though it were implemented (cli.ts:716, :808), and the README says it outright — the route existed in the vendored 4.1.2 spec, cast just never called it. It went unseen because every environment cast had touched until now was hand-built in a UI and adopted, so it already existed under whatever name someone typed. The genuinely-from-nothing apply is the one path nobody had run. apply now reconciles project + environment once per run, before the first create. Read-before-write: an environment that already exists is never written to, so adoption is untouched and this cannot regress an apply that works today. A 409 is read as "present" (the race between our read and our write). Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting it would be the first delete cast ever performs. An empty `production` beside the environment everything lives in is reported, the same courtesy an orphan gets, and removed by hand or not at all. The regression test drives the real failure, not a call count: the fake Coolify 404s a create whose environment_name it does not carry, exactly as a live box does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
if (path === "/api/v1/projects/proj-1/environments")
return new Response(JSON.stringify([{ name: "prod" }]), {
status: 200,
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
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");
});
});
feat: place a resource on a destination — and a state file that can say which (#21) 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>
2026-07-13 19:31:02 +00:00
// 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 },
);
fix: apply creates the environment its resources name (#38) POST /projects hands a new project Coolify's OWN default environment, `production` — never ours. cast then created every resource with `environment_name: <our --env>`, so the first apply against a project that did not exist yet 404'd on its first resource ("Environment not found") and left the project behind, created and empty. Two comments in the source already asserted the behaviour as though it were implemented (cli.ts:716, :808), and the README says it outright — the route existed in the vendored 4.1.2 spec, cast just never called it. It went unseen because every environment cast had touched until now was hand-built in a UI and adopted, so it already existed under whatever name someone typed. The genuinely-from-nothing apply is the one path nobody had run. apply now reconciles project + environment once per run, before the first create. Read-before-write: an environment that already exists is never written to, so adoption is untouched and this cannot regress an apply that works today. A 409 is read as "present" (the race between our read and our write). Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting it would be the first delete cast ever performs. An empty `production` beside the environment everything lives in is reported, the same courtesy an orphan gets, and removed by hand or not at all. The regression test drives the real failure, not a call count: the fake Coolify 404s a create whose environment_name it does not carry, exactly as a live box does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
// 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,
});
feat: place a resource on a destination — and a state file that can say which (#21) 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>
2026-07-13 19:31:02 +00:00
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");
},
);
});
fix: apply creates the environment its resources name (#38) POST /projects hands a new project Coolify's OWN default environment, `production` — never ours. cast then created every resource with `environment_name: <our --env>`, so the first apply against a project that did not exist yet 404'd on its first resource ("Environment not found") and left the project behind, created and empty. Two comments in the source already asserted the behaviour as though it were implemented (cli.ts:716, :808), and the README says it outright — the route existed in the vendored 4.1.2 spec, cast just never called it. It went unseen because every environment cast had touched until now was hand-built in a UI and adopted, so it already existed under whatever name someone typed. The genuinely-from-nothing apply is the one path nobody had run. apply now reconciles project + environment once per run, before the first create. Read-before-write: an environment that already exists is never written to, so adoption is untouched and this cannot regress an apply that works today. A 409 is read as "present" (the race between our read and our write). Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting it would be the first delete cast ever performs. An empty `production` beside the environment everything lives in is reported, the same courtesy an orphan gets, and removed by hand or not at all. The regression test drives the real failure, not a call count: the fake Coolify 404s a create whose environment_name it does not carry, exactly as a live box does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
// #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;
}) {
const projects = opts.projects ?? [];
let environments = opts.environments ?? [];
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 });
}
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: [],
};
function exec(fetchImpl: typeof fetch) {
return buildExecutor(
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
{
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
backupSchedules: {},
},
);
}
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);
});
});
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
it("round-trips through defaultDatabaseImage for postgres", () => {
const image = defaultDatabaseImage("postgresql", "17");
expect(databaseVersionFromImage(image)).toBe("17");
});
});