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
212 lines
7.6 KiB
TypeScript
212 lines
7.6 KiB
TypeScript
import { 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, describe, expect, it } from "vitest";
|
|
import { tmp } from "./helpers/tmp.js";
|
|
|
|
// The sweep, against a stub shaped like the box that made it necessary.
|
|
//
|
|
// Three projects — one ours, two unrelated third-party client sites nobody knew
|
|
// were there. Our project has TWO environments: `production`, which Coolify
|
|
// auto-created and which is EMPTY, and `staging`, which is where the live
|
|
// founder-facing system has been running the whole time because nobody ever
|
|
// swapped it.
|
|
//
|
|
// Pointed at `production`, the old inventory reported "5 differences" against a
|
|
// box whose resources were alive and serving production — the manifest talking
|
|
// to itself, and an impression ("the box has nothing") that is exactly how a
|
|
// full-create plan gets laundered into a pass.
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
const stubs: Stub[] = [];
|
|
|
|
async function stubCoolify(): Promise<Stub> {
|
|
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" },
|
|
{ uuid: "p2", name: "La Familia Site" },
|
|
{ uuid: "p3", name: "Martin Reyes Barber Shop" },
|
|
]);
|
|
if (path === "/projects/p1/environments")
|
|
return json([{ name: "production" }, { name: "staging" }]);
|
|
if (path === "/projects/p2/environments")
|
|
return json([{ name: "production" }]);
|
|
if (path === "/projects/p3/environments")
|
|
return json([{ name: "production" }]);
|
|
// Coolify auto-creates `production`. It is empty. Everything real lives in
|
|
// `staging`, under names a human typed.
|
|
if (path === "/projects/p1/production") return json({});
|
|
if (path === "/projects/p1/staging")
|
|
return json({
|
|
applications: [
|
|
{ name: "Incubator Stack v2", uuid: "a1" },
|
|
{ name: "Incubator Landing", uuid: "a2" },
|
|
],
|
|
postgresqls: [{ name: "Incubator Database v2", uuid: "d1" }],
|
|
services: [{ name: "Incubator Umami", uuid: "s1" }],
|
|
});
|
|
if (path === "/projects/p2/production")
|
|
return json({ applications: [{ name: "lafamilia-web", uuid: "a9" }] });
|
|
if (path === "/projects/p3/production")
|
|
return json({ applications: [{ name: "barber-web", uuid: "a8" }] });
|
|
if (path.endsWith("/envs")) return json([]);
|
|
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"]
|
|
`;
|
|
|
|
function fixture(url: 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`,
|
|
);
|
|
writeFileSync(
|
|
join(state, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" staging:",
|
|
" server: staging-box",
|
|
" team: { id: 0, name: Root Team }",
|
|
"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", "inventory", ...args], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let output = "";
|
|
child.stdout.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stdin.end();
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
});
|
|
}
|
|
|
|
describe("cast inventory — the sweep (#22)", () => {
|
|
it("with no repo, shows every project, environment and resource", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run(["--env", "staging", "--state", f.state]);
|
|
expect(r.code).toBe(0);
|
|
|
|
// Every project the token can see — including the two nobody knew were on
|
|
// the box. (On the real instance, that discovery is what stopped a plan
|
|
// whose next step would have deleted the box.)
|
|
expect(r.output).toContain("Incubator");
|
|
expect(r.output).toContain("La Familia Site");
|
|
expect(r.output).toContain("Martin Reyes Barber Shop");
|
|
|
|
// Both environments, and the empty one is SHOWN, not hidden — an operator
|
|
// who assumes `production` is where things live aims every later command at
|
|
// nothing.
|
|
expect(r.output).toContain("production");
|
|
expect(r.output).toContain("(empty)");
|
|
expect(r.output).toContain("staging");
|
|
|
|
// And the resources, under the names the box actually uses.
|
|
expect(r.output).toContain("Incubator Stack v2");
|
|
expect(r.output).toContain("Incubator Database v2");
|
|
});
|
|
|
|
it("needs no repo, no manifest, no store — it runs before adoption exists", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
// No org/repo positional at all: nothing is cloned, no manifest is read.
|
|
const r = await run(["--env", "staging", "--state", f.state]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain("No manifest involved");
|
|
});
|
|
|
|
it("still asserts the team — a wrong-team token would sweep an empty instance", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
writeFileSync(
|
|
join(f.state, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" staging:",
|
|
" server: staging-box",
|
|
" team: { id: 9, name: Some Other Team }",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
const r = await run(["--env", "staging", "--state", f.state]);
|
|
// Coolify scopes what a token can see to its team, so an unasserted sweep
|
|
// would truthfully report that the instance is empty.
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).not.toContain("La Familia Site");
|
|
});
|
|
});
|
|
|
|
describe("an empty environment shouts (#22)", () => {
|
|
it("says NOTHING is here, and names the sweep — not '5 differences'", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"heavy-duty/incubator",
|
|
"--env",
|
|
"staging",
|
|
"--state",
|
|
f.state,
|
|
"--path",
|
|
f.checkout,
|
|
"--project",
|
|
"Incubator",
|
|
"--environment",
|
|
"production", // auto-created by Coolify, and empty
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
expect(r.output).toContain("NOTHING");
|
|
expect(r.output).toContain("WRONG COORDINATE");
|
|
expect(r.output).toContain("cast inventory --env staging");
|
|
// NOT the old output, whose overall impression was "the box has nothing and
|
|
// the manifest has five things" — a full-create plan in all but name.
|
|
expect(r.output).not.toContain("difference(s) between the manifest");
|
|
});
|
|
});
|