cast/test/placement-cli.test.ts
claude-hdb 1af5eeba0a 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

239 lines
8.8 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// Placement, end to end: `environments.yaml` -> `cast diff` -> what it prints
// and what it exits with. The unit tests prove each half (bindings resolve a
// project's destination; computeDiff groups the live side by it) — this proves
// they are actually wired to each other, which is the half a type checker
// cannot see.
//
// The box here is the shape #21 is about: ONE server carrying more than one
// project, so the server's default network is no longer the right answer for
// anything on it.
let recipient: string;
let keyFile: string;
beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
});
type Stub = { url: string; close: () => Promise<void> };
const stubs: Stub[] = [];
// destinationIds is the knob: which network Coolify says each app is on. The
// live payload is otherwise a byte-for-byte match for the manifest below, so
// anything the diff reports is placement and nothing else.
async function stubCoolify(destinationIds: {
core: number | null;
landing: number | null;
}): Promise<Stub> {
const app = (name: string, uuid: string, destination_id: number | null) => ({
name,
uuid,
git_repository: "heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: `http://${name}.example.com`,
// Coolify returns this on every resource; `null` stands for the box that
// somehow reports none.
destination_id,
});
const server = createServer((req, res) => {
const path = (req.url ?? "").replace("/api/v1", "");
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/projects") return json([{ uuid: "p1", name: "incubator" }]);
if (path === "/projects/p1/staging")
return json({
applications: [
app("core", "a1", destinationIds.core),
app("landing", "a2", destinationIds.landing),
],
});
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
const MANIFEST = `project: incubator
environments:
staging:
applications:
core:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://core.example.com"]
landing:
source: { repo: heavy-duty/incubator, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://landing.example.com"]
`;
// `declared` is the destination_uuid the state file names for this project —
// undefined means the state file says nothing, which is every state file today.
function fixture(url: string, declared?: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
mkdirSync(join(state, "secrets"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
// No template refs any secret, but the store still has to exist and open —
// diff resolves the environment's secrets before it reads anything live.
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
input: "\n",
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: shared-box",
" team: { id: 0, name: Root Team }",
// Keyed by the full slug, and nested under the environment — the shape
// that can say "this project goes HERE and that one goes THERE".
...(declared
? [
" projects:",
" heavy-duty/incubator:",
` destination_uuid: ${declared}`,
]
: []),
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return { checkout, state };
}
function run(args: string[]): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "diff", ...args], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const base = (f: { checkout: string; state: string }) => [
"heavy-duty/incubator",
"--env",
"staging",
"--path",
f.checkout,
"--state",
f.state,
];
describe("cast diff — placement (#21)", () => {
it("reports the destination it declared, and says it could not verify it", async () => {
const f = fixture(
(await stubCoolify({ core: 5, landing: 5 })).url,
"d-abc",
);
const r = await run(base(f));
// Clean: a declared destination is never itself drift. There is nothing on
// the wire to compare it to, and a phantom "update" would never clear.
expect(r.code).toBe(0);
expect(r.output).toContain("placement: all resources on destination 5");
// The load-bearing sentence. cast enforces this UUID once, at create, and
// can never check it again — an operator who reads a clean diff as "the
// isolation is verified" is exactly the person #21 is written for.
expect(r.output).toContain("d-abc");
expect(r.output).toMatch(/NOT compared/);
expect(r.output).toContain("clean");
});
// The failure the issue is actually about: the isolation looks configured and
// isn't. Two of this project's apps, two different networks.
it("fails a split project, naming both sides of the split", async () => {
const f = fixture(
(await stubCoolify({ core: 5, landing: 9 })).url,
"d-abc",
);
const r = await run(base(f));
expect(r.code).toBe(1);
expect(r.output).toMatch(/split placement: these resources sit on 2/);
expect(r.output).toContain("destination 5: application core");
expect(r.output).toContain("destination 9: application landing");
// Reported, never repaired — the orphan disposition.
expect(r.output).toMatch(/apply never moves a live resource between/);
expect(r.output).not.toContain("clean");
});
// A split is a split whether or not the state file has caught up: it is read
// off the live side alone. This is what catches a box where someone made the
// destinations by hand and cast has never been told.
it("catches a split even when no destination is declared", async () => {
const f = fixture((await stubCoolify({ core: 5, landing: 9 })).url);
const r = await run(base(f));
expect(r.code).toBe(1);
expect(r.output).toMatch(/split placement/);
// ...and with nothing declared there is no unverifiable claim to warn about.
expect(r.output).not.toMatch(/NOT compared/);
});
// The state of most boxes today: one project, one server, one network, nothing
// declared. This used to assert silence. #41 made it speak: an undeclared
// destination is cast choosing to let Coolify pick, and a run says which network
// its creates land on rather than leaving it to be inferred from a blank space.
// The clean exit is the part that must not move — an assumption is not drift.
it("names the assumption on an undeclared, unsplit box, and stays clean", async () => {
const f = fixture((await stubCoolify({ core: 5, landing: 5 })).url);
const r = await run(base(f));
expect(r.code).toBe(0);
expect(r.output).toMatch(
/placement: server's default destination \(none declared\)/,
);
expect(r.output).toContain("clean");
// The declared-destination warning is the other branch, and belongs to a run
// that declared one. Saying both would be saying nothing.
expect(r.output).not.toMatch(/NOT compared/);
});
});