cast/test/fleet-cli.test.ts

428 lines
16 KiB
TypeScript
Raw Normal View History

feat: --all — every project in an environment, and a report that says so (#26) Every cast verb was single-project, so "do this to the whole instance" was a shell loop the operator wrote from memory — and the project they forgot is the one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all` iterate the registry (#25) instead. The bulk of this is a refactor: the apply/diff block in cli.ts was one long inline body, and it is now `runProject` — checkout → secrets → desired → bindings → live → diff → optionally apply. Both the single-repo path and the `--all` loop call it, so there is exactly ONE implementation of what a project run is. A second, parallel fleet path is how the two would drift, and drift is the subject of this tool. `openCoolify` and the team assert are hoisted out of it: one --env means one instance and one team, so asserting once still lands strictly before the FIRST project's first read — the read is already the lie. Fails closed on the aggregate. A registered project cast cannot reach is an ERROR, never a skip: the clone failing, no manifest block for this environment, an absent or undecryptable store, an absent Coolify project/environment, any HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/ #22 at fleet scale — so the report leads with COVERAGE (registered / read / clean / drifted / unreachable), and: diff --all 0 every registered project was READ, and every one is clean 1 every one was read, and at least one has drift 2 a project could not be read — outranking drift, because an unreadable project is not a diff result but the absence of one apply --all 0 every registered project applied; non-zero otherwise `diff --all` runs every project to completion (stopping hides the drift in the projects it never reached); `apply --all` STOPS at the first failure and names what it applied and what it did not touch (continuing to mutate a fleet after an unexplained failure is not a thing cast gets to do). Two refusals. An empty or absent registry refuses rather than printing "0 projects, clean" — an empty fleet reading as a clean fleet is the whole failure this is against; the message distinguishes an unmigrated state file from a registry pointed elsewhere and prints the YAML to write. And `--all` is mutually exclusive with the repo positional and with every single-project coordinate (--path, --project, --environment, --resource, --hostname-overlay): each names ONE project's checkout, ONE project's Coolify name, ONE box's resource names, and `--project X` across a fleet would point every project at the same Coolify project — a false report on diff, and on apply every manifest in the fleet written into one project. Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an unmigrated state file "a clean no-op rather than a crash". It is precisely backwards, and now says so. And the --path/--env-prod refusal is hoisted to the CLI's up-front flag validation (one rule, one string, two call sites in resolve.ts) — it used to be caught only by accident of resolveCheckout running before the bindings load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:21:27 +00:00
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";
// `cast diff --all` / `cast apply --all` (#26), end to end against a stub
// Coolify carrying three projects.
//
// The failure this whole feature is about is a report that reads the same
// whether cast looked at everything or at nothing — so every test below is
// really an assertion about COVERAGE: what the run says it read, versus what the
// registry says exists. A skipped project reads exactly like a clean one, and
// the exit code is where that lie would land.
//
// --all forbids --path (it is ONE project's checkout), so these runs take the
// real clone path. `insteadOf` points github.com at local git repos: the clone
// is genuine, the network is not.
const REPOS = ["alpha", "beta", "gamma"] as const;
const SLUGS = REPOS.map((r) => `heavy-duty/${r}`);
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();
});
// What the box does when cast asks about a project:
// clean — exactly what the manifest declares
// drift — the same app on the wrong branch
// absent — no such project on this Coolify (LiveLookup.found === false)
// error — the environment read 500s (any HTTP error at all)
type Behavior = "clean" | "drift" | "absent" | "error";
type Stub = { url: string; hits: string[]; close: () => Promise<void> };
const stubs: Stub[] = [];
async function stubCoolify(
behavior: Partial<Record<string, Behavior>> = {},
): Promise<Stub> {
const of = (repo: string): Behavior => behavior[repo] ?? "clean";
const hits: string[] = [];
const server = createServer((req, res) => {
const path = (req.url ?? "").replace("/api/v1", "");
hits.push(path);
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 === "/servers") return json([{ uuid: "s1", name: "fleet-box" }]);
if (path === "/github-apps")
return json([{ uuid: "g1", name: "hdb-coolify" }]);
if (path === "/projects") {
return json(
REPOS.filter((r) => of(r) !== "absent").map((r) => ({
uuid: `p-${r}`,
name: r,
})),
);
}
for (const repo of REPOS) {
if (path === `/projects/p-${repo}/staging`) {
if (of(repo) === "error") {
res.writeHead(500);
return res.end("boom");
}
return json({
applications: [
{
name: "core",
uuid: `a-${repo}`,
git_repository: `heavy-duty/${repo}`,
git_branch:
of(repo) === "drift" ? "someones-feature-branch" : "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: `http://${repo}.example.com`,
destination_id: 1,
},
],
});
}
if (path === `/applications/a-${repo}/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}`,
hits,
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 = (repo: string) => `project: ${repo}
environments:
staging:
applications:
core:
source: { repo: heavy-duty/${repo}, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://${repo}.example.com"]
`;
// A state dir + three clonable product repos. `registry` is the knob: which
// slugs the `projects:` block registers for staging — undefined writes no
// `projects:` block at all (a state file from before the registry existed).
function fixture(
url: string,
opts: { registry?: string[]; registryEnv?: string } = {},
) {
const root = mkdtempSync(join(tmpdir(), "cast-fleet-"));
for (const repo of REPOS) {
const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest(repo));
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: dir, stdio: "pipe" });
git("init", "-q");
git("add", "-A");
git(
"-c",
"user.email=cast@example.com",
"-c",
"user.name=cast",
"commit",
"-qm",
"manifest",
);
}
const state = join(root, "state");
mkdirSync(join(state, "secrets"), { recursive: true });
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
for (const repo of REPOS) {
// No template refs a secret, but the store still has to exist and open —
// a project whose store is missing is a project cast cannot read, which is
// a fleet ERROR, not a fleet skip (asserted below).
execFileSync("age", ["-r", recipient, "-o", `${repo}.staging.env.age`], {
input: "\n",
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
}
const registry = opts.registry ?? SLUGS;
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: fleet-box",
" team: { id: 0, name: Root Team }",
// A second environment, so "registered, but not HERE" is a state this
// fixture can express — it is the difference between a fleet nobody has
// registered and a fleet registered somewhere else.
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
"github_apps:",
...SLUGS.map((s) => ` ${s}: hdb-coolify`),
...(registry.length > 0
? [
"projects:",
...registry.flatMap((slug) => [
` ${slug}:`,
` environments: [${opts.registryEnv ?? "staging"}]`,
]),
]
: []),
"",
].join("\n"),
);
return { root, state };
}
function run(
verb: "apply" | "diff",
args: string[],
f?: { root: string },
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", verb, ...args], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
CAST_AGE_KEY_FILE_STAGING: keyFile,
// The clone is real; only its origin is local. cast builds the URL as
// https://github.com/<org>/<repo>.git and git rewrites it here.
...(f
? {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${join(f.root, "repos")}/.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/",
}
: {}),
},
});
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 fleet = (f: { state: string }) => [
"--env",
"staging",
"--state",
f.state,
"--all",
];
describe("cast diff --all (#26)", () => {
it("iterates the registry, reports each project, and exits 0 on a clean fleet", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(0);
for (const slug of SLUGS) expect(r.output).toContain(slug);
expect(r.output).toContain("[1/3] heavy-duty/alpha");
expect(r.output).toContain("[3/3] heavy-duty/gamma");
// Coverage, said out loud. "clean" alone would be exactly the sentence a
// fleet run over nothing at all prints.
expect(r.output).toContain("registered: 3");
expect(r.output).toContain("read: 3 of 3");
expect(r.output).toContain(
"all 3 registered project(s) were read, and every one is clean.",
);
});
it("exits 1 on drift, naming the drifted project and still reporting the rest", async () => {
const f = fixture((await stubCoolify({ beta: "drift" })).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(1);
expect(r.output).toContain("someones-feature-branch");
expect(r.output).toContain("read: 3 of 3");
expect(r.output).toContain("drift: 1 heavy-duty/beta");
expect(r.output).toContain("clean: 2");
});
// The issue, in one test: a project cast could not reach is an ERROR, not a
// skip — and it does not stop the read either, because stopping would hide
// the drift in the projects never reached.
it("fails the fleet on an unreachable project, and still reads the ones after it", async () => {
const f = fixture(
(await stubCoolify({ beta: "absent", gamma: "drift" })).url,
);
const r = await run("diff", fleet(f), f);
// 2, not 1: an unreadable project is not a diff result, so it outranks the
// drift that WAS found.
expect(r.code).toBe(2);
expect(r.output).toContain("[3/3] heavy-duty/gamma");
expect(r.output).toContain("someones-feature-branch");
expect(r.output).toContain("read: 2 of 3");
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
expect(r.output).toContain("FAILURE, not a diff result");
expect(r.output).not.toContain("every one is clean");
});
it("treats an HTTP error as unreachable, never as an empty project", async () => {
const f = fixture((await stubCoolify({ beta: "error" })).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
expect(r.output).toContain("500");
});
it("treats a missing secret store as unreachable, naming the file", async () => {
const f = fixture((await stubCoolify()).url);
execFileSync("rm", [join(f.state, "secrets", "beta.staging.env.age")]);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
"no secret store for heavy-duty/beta in staging",
);
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
});
});
describe("cast diff --all — an empty fleet is not a clean fleet", () => {
it("refuses a state file with no registry at all", async () => {
const f = fixture((await stubCoolify()).url, { registry: [] });
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
'refusing to diff --all: no projects are registered for "staging"',
);
expect(r.output).toContain("no `projects:` block at all");
expect(r.output).toContain("environments: [staging]");
// The thing it must NOT have done — the ONLY reason this refusal exists.
// (The refusal quotes the phrase "0 projects, clean" to name the lie, so
// the assertion is on the verdict line a real run would have printed.)
expect(r.output).not.toContain("read:");
expect(r.output).not.toContain("were read, and every one is clean");
});
// The registry is not empty — it just has nothing to say about THIS
// environment. Same refusal, because it is the same silence: there is nothing
// to iterate, and a run over nothing must never print what a clean run prints.
it("refuses a registry that registers every project somewhere else", async () => {
const f = fixture((await stubCoolify()).url, { registryEnv: "prod" });
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
"a registry, but nothing registered for this environment",
);
expect(r.output).toContain("heavy-duty/alpha (prod)");
});
});
describe("cast diff/apply --all — mutually exclusive coordinates", () => {
const cases: Array<[string, string[]]> = [
["<org>/<repo>", ["heavy-duty/alpha"]],
["--path", ["--path", "/tmp/somewhere"]],
["--project", ["--project", "Incubator"]],
["--environment", ["--environment", "production"]],
["--resource", ["--resource", "core=Stack v2"]],
["--hostname-overlay", ["--hostname-overlay", "/tmp/overlay.yaml"]],
];
it.each(cases)("refuses --all with %s", async (flag, args) => {
// No state dir and no Coolify: the refusal lands before cast opens either.
const r = await run("diff", ["--env", "staging", "--all", ...args]);
expect(r.code).toBe(2);
expect(r.output).toContain(
`refusing to diff: --all cannot be combined with ${flag}`,
);
});
it("refuses on apply too, and says so as apply", async () => {
const r = await run("apply", [
"--env",
"staging",
"--all",
"--project",
"Incubator",
]);
expect(r.code).toBe(2);
expect(r.output).toContain(
"refusing to apply: --all cannot be combined with --project",
);
});
});
// The prod ban is older than --all (a feature-branch checkout must not decide
// what prod runs), but --all is what moved it: hoisting loadBindings for the
// registry put it AFTER the point where resolveCheckout used to catch this, so
// the CLI now refuses up front and resolveCheckout still throws behind it. Two
// call sites, one rule — and the up-front one is a code path of its own, so it
// gets a test of its own rather than riding on resolve.test.ts's.
describe("--path with --env prod is refused before anything is opened", () => {
it.each(["diff", "apply"] as const)(
"refuses on %s with no state dir, no store and no Coolify",
async (verb) => {
const r = await run(verb, [
"heavy-duty/alpha",
"--env",
"prod",
"--path",
"/tmp/somewhere",
]);
expect(r.code).toBe(2);
expect(r.output).toContain(
"refuses --path with --env prod: prod always reads the default branch",
);
},
);
});
describe("cast apply --all (#26)", () => {
it("applies every registered project and says what it did", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run("apply", fleet(f), f);
expect(r.code).toBe(0);
expect(r.output).toContain("applied: 3 of 3");
expect(r.output).toContain("all 3 registered project(s) applied.");
expect(r.output).toContain(
"nothing changed — the fleet already matched its manifests.",
);
});
// The one disposition `apply` does not share with `diff`: it STOPS. Half a
// fleet mutated after an unexplained failure is not a fleet cast keeps writing
// to — and the report has to say which half.
it("stops at the first failure, and names what it did and did not touch", async () => {
const stub = await stubCoolify({ beta: "error" });
const f = fixture(stub.url);
const r = await run("apply", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain("STOPPED at the first failure");
expect(r.output).toContain("applied: 1 of 3 heavy-duty/alpha");
expect(r.output).toContain("FAILED: heavy-duty/beta");
expect(r.output).toContain("not reached: 1 heavy-duty/gamma");
expect(r.output).toContain('"not reached" were NOT touched');
// It did not merely SAY it stopped: gamma was never run…
expect(r.output).not.toContain("[3/3] heavy-duty/gamma");
// …and the box was never asked about it.
expect(stub.hits).not.toContain("/projects/p-gamma/staging");
});
});