cast/test/fleet.test.ts

215 lines
7.2 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 { describe, expect, it } from "vitest";
import { loadBindings } from "../src/bindings.js";
import {
type ProjectOutcome,
fleetConflict,
fleetExitCode,
renderEmptyRegistry,
renderFleetApply,
renderFleetDiff,
} from "../src/fleet.js";
const REGISTERED = ["heavy-duty/alpha", "heavy-duty/beta", "heavy-duty/gamma"];
const clean = (repo: string): ProjectOutcome => ({ repo, status: "clean" });
const drift = (repo: string): ProjectOutcome => ({ repo, status: "drift" });
const applied = (repo: string, mutated: string[] = []): ProjectOutcome => ({
repo,
status: "applied",
mutated,
});
const unreachable = (repo: string, message: string): ProjectOutcome => ({
repo,
status: "unreachable",
message,
});
// The contract, as a table. Everything else in this feature is prose a human
// reads; this is the part CI reads, and the only part that can gate a pipeline.
describe("fleetExitCode", () => {
it("diff: 0 only when every registered project was READ and every one is clean", () => {
expect(fleetExitCode("diff", REGISTERED, REGISTERED.map(clean))).toBe(0);
});
it("diff: 1 when all were read and at least one drifted", () => {
expect(
fleetExitCode("diff", REGISTERED, [
clean("heavy-duty/alpha"),
drift("heavy-duty/beta"),
clean("heavy-duty/gamma"),
]),
).toBe(1);
});
it("diff: 2 when a project could not be read — outranking drift", () => {
// The whole point of the ranking: an unreadable project is not a diff
// result, it is the ABSENCE of one. A run that reported 1 here would be
// saying "the fleet has drift", when what it actually has is a hole.
expect(
fleetExitCode("diff", REGISTERED, [
clean("heavy-duty/alpha"),
unreachable("heavy-duty/beta", "refusing to diff: no project named…"),
drift("heavy-duty/gamma"),
]),
).toBe(2);
});
it("diff: 2 when the outcomes do not cover the registry at all", () => {
// Fails closed on a coverage gap it does not otherwise recognize. A clean
// outcome for two of three projects is not a clean fleet — and the default
// an unrecognized shape falls into must never be 0.
expect(
fleetExitCode("diff", REGISTERED, [
clean("heavy-duty/alpha"),
clean("heavy-duty/beta"),
]),
).toBe(2);
});
it("apply: 0 only when every registered project applied", () => {
expect(
fleetExitCode(
"apply",
REGISTERED,
REGISTERED.map((r) => applied(r)),
),
).toBe(0);
expect(
fleetExitCode("apply", REGISTERED, [
applied("heavy-duty/alpha", ["core"]),
unreachable("heavy-duty/beta", "GET /projects/p2/staging → 500: boom"),
]),
).toBe(2);
});
});
describe("renderFleetDiff", () => {
it("states coverage, not just findings — a clean fleet says how many it read", () => {
const out = renderFleetDiff("prod", REGISTERED, REGISTERED.map(clean));
expect(out).toContain("registered: 3");
expect(out).toContain("read: 3 of 3");
expect(out).toContain(
"all 3 registered project(s) were read, and every one is clean.",
);
});
it("never lets an unreachable project read like a clean one", () => {
const out = renderFleetDiff("prod", REGISTERED, [
clean("heavy-duty/alpha"),
unreachable(
"heavy-duty/beta",
'refusing to diff: no project named "beta" exists in this team\n\n looked for: …',
),
clean("heavy-duty/gamma"),
]);
expect(out).toContain("read: 2 of 3");
expect(out).toContain("UNREACHABLE: 1 heavy-duty/beta");
// The headline of the refusal travels with the summary; the body of it was
// printed in that project's own section.
expect(out).toContain('no project named "beta" exists in this team');
expect(out).not.toContain("looked for:");
expect(out).toContain("FAILURE, not a diff result");
expect(out).not.toContain("every one is clean");
});
});
describe("renderFleetApply", () => {
it("says what it applied when it applied everything", () => {
const out = renderFleetApply("prod", REGISTERED, [
applied("heavy-duty/alpha", ["core"]),
applied("heavy-duty/beta"),
applied("heavy-duty/gamma"),
]);
expect(out).toContain("applied: 3 of 3");
expect(out).toContain("heavy-duty/alpha: core");
expect(out).toContain("all 3 registered project(s) applied.");
});
it("says what it did AND what it did not touch when it stopped", () => {
const out = renderFleetApply("prod", REGISTERED, [
applied("heavy-duty/alpha", ["core"]),
unreachable("heavy-duty/beta", "GET /projects/p2/staging → 500: boom"),
]);
expect(out).toContain("applied: 1 of 3 heavy-duty/alpha");
expect(out).toContain("FAILED: heavy-duty/beta");
expect(out).toContain("not reached: 1 heavy-duty/gamma");
// The sentence an operator has to be able to trust before re-running.
expect(out).toContain('"not reached" were NOT touched');
expect(out).toContain("STOPPED at the first failure");
});
});
describe("renderEmptyRegistry", () => {
const bindings = (extra: string[]) =>
loadBindings("(inline)", {
overrideText: [
"environments:",
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
" staging:",
" server: staging-box",
" team: { id: 0, name: Root Team }",
"github_apps:",
" heavy-duty/alpha: hdb-coolify",
...extra,
"",
].join("\n"),
});
it("names the environment, the file, and the YAML to write", () => {
const out = renderEmptyRegistry(
"diff",
"prod",
bindings([]),
"/state/environments.yaml",
);
expect(out).toContain(
'refusing to diff --all: no projects are registered for "prod"',
);
expect(out).toContain("/state/environments.yaml");
expect(out).toContain("no `projects:` block at all");
expect(out).toContain("environments: [prod]");
// The sentence the refusal exists for.
expect(out).toContain('"0 projects, clean"');
});
it("distinguishes an unmigrated state file from one registered elsewhere", () => {
const out = renderEmptyRegistry(
"apply",
"prod",
bindings([
"projects:",
" heavy-duty/alpha:",
" environments: [staging]",
]),
"/state/environments.yaml",
);
expect(out).toContain(
"a registry, but nothing registered for this environment",
);
expect(out).toContain("heavy-duty/alpha (staging)");
expect(out).not.toContain("no `projects:` block at all");
});
});
describe("fleetConflict", () => {
it("names the offending single-project coordinate", () => {
expect(fleetConflict({ "--project": "Incubator" })).toBe("--project");
expect(fleetConflict({ "--resource": ["core=Stack v2"] })).toBe(
"--resource",
);
expect(fleetConflict({ "<org>/<repo>": "heavy-duty/alpha" })).toBe(
"<org>/<repo>",
);
});
it("passes a legitimate fleet invocation", () => {
// parseArgs hands back `[]` for an unused repeatable flag, not `undefined`.
expect(
fleetConflict({ "--resource": [], "--path": undefined }),
).toBeUndefined();
expect(fleetConflict({})).toBeUndefined();
});
});