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",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
});
|
|
|
|
|
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",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
});
|
|
|
|
|
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",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
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",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
});
|
|
|
|
|
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;
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
// 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;
|
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
|
|
|
}) {
|
|
|
|
|
const projects = opts.projects ?? [];
|
|
|
|
|
let environments = opts.environments ?? [];
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
const holds = opts.holds ?? {};
|
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
|
|
|
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 });
|
|
|
|
|
}
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
// 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 },
|
|
|
|
|
);
|
|
|
|
|
}
|
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/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: [],
|
|
|
|
|
};
|
|
|
|
|
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
// 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") {
|
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
|
|
|
return buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
{
|
|
|
|
|
projectName: "widget",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
envName,
|
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
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
|
|
|
|
|
// #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/);
|
|
|
|
|
});
|
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
|
|
|
});
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
|
|
|
|
|
|
|
|
// 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/);
|
|
|
|
|
});
|
|
|
|
|
});
|