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>
239 lines
8.8 KiB
TypeScript
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/);
|
|
});
|
|
});
|