cast/test/placement-cli.test.ts
dan-claude-bot 7f0e886851 fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.

The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.

Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.

Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.

Refs #117
2026-07-21 12:58:14 +00:00

239 lines
8.8 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { tmp } from "./helpers/tmp.js";
// 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 = tmp("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 = tmp("cast-co-");
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = tmp("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/);
});
});