From 8dde6dbc05325a550aaafe20fc7c010cb3abb7f3 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:21:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20--all=20=E2=80=94=20every=20project=20i?= =?UTF-8?q?n=20an=20environment,=20and=20a=20report=20that=20says=20so=20(?= =?UTF-8?q?#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 73 ++++++- docs/semantics.md | 82 +++++++- src/bindings.ts | 11 +- src/cli.ts | 397 +++++++++++++++++++++++++++++--------- src/fleet.ts | 320 ++++++++++++++++++++++++++++++ src/resolve.ts | 29 ++- test/fleet-cli.test.ts | 427 +++++++++++++++++++++++++++++++++++++++++ test/fleet.test.ts | 214 +++++++++++++++++++++ 8 files changed, 1447 insertions(+), 106 deletions(-) create mode 100644 src/fleet.ts create mode 100644 test/fleet-cli.test.ts create mode 100644 test/fleet.test.ts diff --git a/README.md b/README.md index 6441a00..a183cf5 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ Pass it with `--state `, or set `CAST_STATE`. Defaults to the cwd. ```sh cast apply / --env [--path ] [--hostname-overlay ] +cast apply --env --all # no repo: EVERY registered project cast diff / --env [--full] +cast diff --env --all [--full] # no repo: EVERY registered project cast capture / --env [--generated ] [--override ] cast inventory / --env cast server add --ip --key --env [--user root] [--port 22] @@ -79,6 +81,9 @@ cast team [--env ] default branch). - **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full` also compares env vars. Exits non-zero when dirty, so CI can gate on it. +- **`--all`** — on `apply`/`diff`, act on **every project the registry lists for + this environment** instead of one named repo. See *The whole environment at + once* below. - **`inventory`** — what is actually *on* a box. **With no repo it sweeps the instance** (every project, every environment, every resource — no manifest involved); with a repo it reconciles, showing resources and env var **keys** @@ -402,11 +407,77 @@ a clean one.** Silence is the one report that must never be ambiguous. What it unlocks, neither of which was possible without a list to iterate: - **fleet operations** — `cast diff --all` / `apply --all` over every project in - an environment. + an environment (below). - **rebuild-from-state** — "restore this Coolify from the state repo" cannot even be *attempted* without knowing what was on it. The registry is the difference between a documented recovery and an archaeology exercise. +## The whole environment at once: `--all` + +Every other cast verb is 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.** `--all` iterates the registry instead: + +```sh +cast diff --env prod --all # every project registered for prod +cast apply --env prod --all +``` + +It runs the **same per-project path** the single-repo form runs (there is one +implementation of what a project run *is*), reports each project under its own +heading, and then prints an aggregate that leads with **coverage**: + +``` +fleet diff — prod + + registered: 3 (heavy-duty/incubator, acme/client-site, acme/landing) + read: 2 of 3 + clean: 1 heavy-duty/incubator + drift: 1 acme/landing + UNREACHABLE: 1 acme/client-site + acme/client-site: refusing to diff: no project named "client-site" … +``` + +**A registered project cast cannot reach is an ERROR, not a skip** — the clone +failing, no manifest block for this environment, a missing or undecryptable +store, an absent Coolify project or environment, any HTTP error. A skipped +project reads exactly like a clean one, which would make the one report you get +most often (silence) the one you cannot trust. So the fleet **fails closed**: a +clean fleet diff means *every* project was read, not that the ones cast happened +to look at were fine. + +| | | +|---|---| +| `diff --all` exit 0 | every registered project was **read**, and every one is clean | +| `diff --all` exit 1 | every one was read, and at least one has drift | +| `diff --all` exit 2 | a project could not be read — **outranks drift**, because an unread project is not a diff result, it is the absence of one | +| `apply --all` exit 0 | every registered project applied | +| `apply --all` non-zero | anything else | + +The two verbs take opposite dispositions on failure, and both are deliberate: + +- **`diff --all` runs every project to completion.** Stopping early would hide the + drift in the projects it never reached — a partial read is exactly the report + this flag exists to make impossible. +- **`apply --all` stops at the first failure**, and says which projects it + applied and which it did not touch. Continuing to *mutate* a fleet after an + unexplained failure is not a thing cast gets to do. `apply` is idempotent, so + re-running after a fix is a no-op over the ones that already applied. + +**An empty or absent registry refuses** (exit 2). `--all` over a state file with +no `projects:` block does *not* print "0 projects, clean" and exit 0 — an empty +fleet reading as a clean fleet is the whole failure this feature is against. The +refusal names what it looked for and prints the YAML to write. + +**`--all` is mutually exclusive** with the repo positional and with every +single-project coordinate — `--path`, `--project`, `--environment`, `--resource`, +`--hostname-overlay`. Each of those names ONE project's checkout, ONE project's +Coolify name, ONE box's resource names; fleet-wide they are meaningless at best +and dangerous at worst (`--project X` applied to every project in the registry +would point them all at the same Coolify project — a false report on `diff`, and +on `apply` every manifest in the fleet written into one project). The refusal +names the offending flag. + ## Two projects, one box: destinations A **destination** is the Docker network a resource is created on. A server has a diff --git a/docs/semantics.md b/docs/semantics.md index 668b284..aa81ec1 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -199,10 +199,90 @@ ambiguous: present**, so pre-registry state files keep loading. The registry is what makes two things possible, neither of which can be attempted -without a list to iterate: **fleet operations** (`--all`), and +without a list to iterate: **fleet operations** (`--all`, below), and **rebuild-from-state** — restoring a Coolify from the state repo, which is otherwise an assumption, since you cannot restore what you cannot enumerate. +## Fleet runs (`--all`) + +`cast diff --env --all` and `cast apply --env --all` act on **every +project the registry lists for that environment**, in place of the `/` +positional. The projects are visited in the registry's sorted order +(`projectsIn`) — a fleet report a human reads top to bottom, and CI diffs, must +not reshuffle because someone appended a project. + +**One implementation.** `--all` loops the *same* per-project path the single-repo +form runs (checkout → secrets → desired → bindings → team-asserted client → live +read → diff → optionally apply). There is deliberately no second, parallel fleet +code path: two implementations of "what a project run is" would drift, and drift +is the thing this tool exists to catch. + +**The instance and the team are asserted once, before the first project's first +read.** One `--env` means one instance and one team for the whole run, so the +gate lands where it always did — strictly before the first live read, which is +already the lie a wrong-team token tells (see *Team scoping*). + +### Fails closed on the aggregate + +**A registered project cast cannot reach is an ERROR, never a skip.** "Cannot +reach" is every way a project can fail to answer: the clone failing, the manifest +carrying no block for this environment, the secret store being absent or +undecryptable, the Coolify project or environment being absent +(`LiveLookup.found === false`), and any HTTP error. They collapse into one +outcome because only one thing about them matters downstream — **this project was +not read** — and a silently skipped project reads exactly like a clean one. That +is the failure of #12/#18/#22 at fleet scale, and it would make *silence*, the +most common report there is, the least trustworthy one. + +So the aggregate reports **coverage** (registered / read / clean / drifted / +unreachable), and the exit code ranks an unread project above a drifted one: + +| verb | exit | meaning | +|---|---|---| +| `diff --all` | `0` | every registered project was **read**, and every one is clean | +| `diff --all` | `1` | every one was read, and at least one has drift | +| `diff --all` | `2` | a project could not be read. **Outranks drift**: an unreadable project is not a diff result, it is the absence of one | +| `apply --all` | `0` | every registered project applied | +| `apply --all` | `≠0` | anything else | + +`fleetExitCode` defaults to `2` on any coverage shape it does not recognize — an +exit code is the only part of the report CI reads, so an unrecognized shape must +fail rather than pass. + +### Opposite dispositions on failure, both deliberate + +- **`diff --all` runs every project to completion.** A read that stops early hides + the drift in the projects it never reached; a read that continues costs nothing. +- **`apply --all` stops at the first failure**, and reports which projects were + applied and which were **not touched**. A write that continues costs everything: + the next project's apply would be a guess about whether the last one broke + something it depends on. `apply` is idempotent, so re-running after the fix is a + no-op over the projects that already applied. + +`apply` keeps its usual position on an absent Coolify project — it *creates* it, +exactly as a single-project `apply` does (see *the read side*, and `LiveLookup`). +Only `diff` treats absence as unreachable, because `diff` may only ever describe a +target that already exists. + +### Two refusals + +- **An empty or absent registry refuses** (exit 2). `projectsIn` answers `[]` both + for a state file with no `projects:` block and for one whose registry names + nothing in this environment; to a fleet run they are the same thing — *nothing + to iterate* — and "0 projects, clean" is precisely the sentence this feature + exists to make impossible. The refusal names what was looked for, distinguishes + an unmigrated state file from a registry pointed elsewhere, and prints the YAML + to write. +- **`--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 — none is true of the project beside it. + Fleet-wide they are meaningless at best and dangerous at worst: `--project X` + across a registry points every project at the same Coolify project, which on + `diff` is a false report and on `apply` is every manifest in the fleet written + into one project. The refusal names the offending flag and says what applying it + fleet-wide would have done. + ## Placement (destinations) A **destination** is the Docker network a resource is created on. It is declared diff --git a/src/bindings.ts b/src/bindings.ts index c0ff16b..66be606 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -368,9 +368,14 @@ export function smokeTargetFor( // registry is a set; this returns it as one. // // `[]` when there is no registry, which is every state file written before this -// block existed. That is the honest answer for "which projects are registered -// here" when nothing is registered anywhere — and it makes a fleet verb over an -// unmigrated state file a clean no-op rather than a crash. +// block existed. That is the honest answer to "which projects are registered +// here" when nothing is registered anywhere — and it is emphatically NOT a +// licence to do nothing with it: the fleet verbs REFUSE on an empty list (#26, +// renderEmptyRegistry). An earlier draft of this comment guessed that `[]` would +// make `--all` over an unmigrated state file "a clean no-op rather than a crash", +// which is precisely backwards — "0 projects, clean" is a clean fleet's report +// printed over a fleet nobody looked at. There is no honest no-op here; there is +// only a refusal that says what it looked for. export function projectsIn(bindings: Bindings, envName: string): string[] { const registry = bindings.projects; if (!registry) return []; diff --git a/src/cli.ts b/src/cli.ts index 99c97bd..6d8ad57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,9 +6,11 @@ import { parseArgs } from "node:util"; import { parse as parseYaml } from "yaml"; import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js"; import { + type Bindings, githubAppNameFor, loadBindings, projectBindingFor, + projectsIn, smokeTargetFor, } from "./bindings.js"; import { @@ -33,6 +35,16 @@ import { renderDiff, } from "./diff.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; +import { + type ProjectOutcome, + fleetConflict, + fleetExitCode, + renderEmptyRegistry, + renderFleetApply, + renderFleetConflict, + renderFleetDiff, + renderProjectHeading, +} from "./fleet.js"; import { type LiveResource, type SweepEnvironment, @@ -42,8 +54,10 @@ import { renderSweep, } from "./inventory.js"; import { + PATH_IN_PROD_REFUSAL, desiredFromManifest, manifestResources, + refusesPathInProd, requiredSecrets, resolveCheckout, } from "./resolve.js"; @@ -58,7 +72,9 @@ import { smoke } from "./smoke.js"; import { assertTeam, formatTeam } from "./team.js"; const USAGE = `usage: cast apply / --env [--path ] [--project ] [--environment ] [--hostname-overlay ] + cast apply --env --all # no repo: EVERY registered project cast diff / --env [--full] [--project ] [--environment ] + cast diff --env --all [--full] # no repo: EVERY registered project cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force] cast inventory / --env [--path ] [--project ] [--environment ] [--resource =] cast inventory --env [--instance ] # no repo: SWEEP the whole instance @@ -99,6 +115,20 @@ const USAGE = `usage: cast apply / --env [--path ] [-- a manifest names them for a diff (\`core\`). Repeatable. Read-side only (\`diff\`, \`capture\`, \`inventory\`) — \`apply\` creates under the manifest's names and refuses this flag. + --all (\`apply\`/\`diff\`) act on EVERY project the \`projects:\` registry + lists for this environment, instead of one named repo — the loop + the operator used to write from memory, and the project they + forgot is the one that drifted. Reports per project and fails + CLOSED on the aggregate: a registered project cast cannot reach + is an ERROR, never a skip, because a skipped project reads + exactly like a clean one. \`diff --all\` runs every project to + completion and exits 2 if any could not be read (which outranks + drift's 1 — an unread project is not a diff result); \`apply --all\` + stops at the first failure and says what it did and did not + touch. An empty (or absent) registry refuses. Mutually exclusive + with the repo positional and with every single-project + coordinate: --path, --project, --environment, --resource, + --hostname-overlay. capture (adopt a hand-built instance into the age secret store): --generated force NAME to the \`pending-coolify-generated\` placeholder, @@ -541,6 +571,168 @@ async function confirmCapture(envName: string): Promise { return answer?.trim() === envName; } +// Everything ONE project run needs that is the same for every project in a +// fleet run: the instance it talks to (one client, one asserted team), the +// bindings, the environment. The single-project coordinates are here too and are +// always undefined under --all — the refusal above is what guarantees that, and +// it is why this one function can serve both paths without a branch inside it. +type ProjectRunContext = { + command: "apply" | "diff"; + stateDir: string; + envName: string; + bindings: Bindings; + binding: Bindings["environments"][string]; + client: CoolifyClient; + mode: "structural" | "full"; + path?: string; + projectOverride?: string; + environmentOverride?: string; + resources: string[]; + hostnameOverlay?: string; +}; + +// What one project's run came to. `absent` is the one failure this function +// RETURNS rather than throws, because it is the one the caller has always +// handled itself (renderAbsentTarget, exit 2) — everything else throws, and the +// fleet loop turns a throw into an unreachable project. +type ProjectResult = + | { status: "clean" } + | { status: "drift" } + | { status: "applied"; mutated: string[] } + | { status: "absent"; message: string }; + +// ONE project, end to end: checkout → secrets → desired → bindings → live → +// diff → (apply). There is exactly one implementation of what a project run IS, +// and both `cast diff ` and `cast diff --all` call it — a second, parallel +// fleet path would be a second thing to keep true, and the two would drift the +// first time either was touched. That drift is the whole subject of this tool. +async function runProject( + ctx: ProjectRunContext, + orgRepo: string, +): Promise { + const repoShort = orgRepo.split("/")[1]; + // The Coolify project name and the secrets-file key are different things + // that happen to default to the same string. Only the former is a name + // some other system chose: a project built by hand in the UI is called + // whatever someone typed. --project overrides that one, and nothing else — + // secrets stay keyed by the repo (a state-repo convention we own). + const projectName = ctx.projectOverride ?? repoShort; + // Exactly the same split, one level down. `--env` is OUR name for the + // environment: it selects the manifest block, the environments.yaml + // binding, the age key, the store path, the team to assert. `--environment` + // is THEIR name for it on the wire, and nothing else. Collapsing the two + // (as cast did until now) means a box built by hand in someone's UI gets to + // name our environment — and since apply creates the environment from this + // value, a legacy box's accident would be inherited by the new one forever. + const coolifyEnv = ctx.environmentOverride ?? ctx.envName; + const checkout = resolveCheckout(orgRepo, { + env: ctx.envName, + path: ctx.path, + }); + const store = secretsFileFor(ctx.stateDir, repoShort, ctx.envName); + // Named, rather than left to `age` to fail on. A registered project whose + // store was never written is a project a fleet run cannot read — and under + // --all the headline of this message is what the summary carries, so it has to + // say which project and which file rather than "Command failed: age -d". + if (!existsSync(store)) { + throw new Error( + [ + `no secret store for ${orgRepo} in ${ctx.envName}`, + "", + ` looked for: ${store}`, + "", + "The manifest's ${…} refs are resolved from that store, so there is nothing to", + "diff or apply without it. `cast capture` writes one from a live box.", + ].join("\n"), + ); + } + const secrets = decryptSecrets(store, keyFileFor(ctx.envName)); + let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest( + checkout, + ctx.envName, + secrets, + ); + assertEnvVarPolicy( + ctx.envName, + resolvedEnvs, + ctx.binding.forbidden_var_patterns, + ); + // Keyed by the REPO, not by --project: --project is the name Coolify's own + // UI happens to use for this project, and cast's state is keyed by the name + // WE own (same split as the secrets file — see the --project note above). + const projectBinding = projectBindingFor(ctx.bindings, ctx.envName, orgRepo); + if (ctx.hostnameOverlay) { + desired = applyHostnameOverlay( + desired, + parseYaml(readFileSync(ctx.hostnameOverlay, "utf8")), + ); + } + const lookup = await fetchLive(ctx.client, projectName, coolifyEnv); + // apply and diff take opposite (and both correct) positions on absence: + // apply is *allowed* to be the thing that brings a project into existence, + // so [] is a legitimate starting point. diff is only ever a claim about + // something that already exists — for it, absence is not an empty diff, it + // is the absence of anything to diff against, and reporting a full-create + // plan would launder that into a pass. See LiveLookup. + // + // That split holds under --all unchanged: a fleet diff counts an absent + // project as UNREACHABLE (it read nothing, so it may claim nothing), while a + // fleet apply creates it, exactly as a single apply would. + if (!lookup.found && ctx.command === "diff") { + return { + status: "absent", + message: renderAbsentTarget(lookup, { + orgRepo, + overridden: ctx.projectOverride !== undefined, + envOverridden: ctx.environmentOverride !== undefined, + }), + }; + } + const aliases = parseResourceAliases( + ctx.resources, + desired.map((d) => d.name), + ); + // Without this, a diff against a box that names things differently reports + // every manifest resource as "to create" and every live one as unknown — + // the D-237 lie by another route: a confident full-create plan that verified + // nothing, against a box that has all of it under other names. + const live = lookup.found ? aliasLive(lookup.live, aliases) : []; + if (ctx.mode === "full") { + for (const l of live) { + l.env = await fetchEnv(ctx.client, l); + } + } + const report = computeDiff(desired, live, ctx.mode, { + declaredDestination: projectBinding?.destination_uuid, + }); + console.log(renderDiff(report)); + if (ctx.command === "diff") + return report.clean ? { status: "clean" } : { status: "drift" }; + const serverUuid = await ctx.client.serverUuid(ctx.binding.server); + const githubAppUuid = await ctx.client.githubAppUuid( + githubAppNameFor(ctx.bindings, orgRepo), + ); + const exec = buildExecutor(ctx.client, { + projectName, + // The name the environment gets ON COOLIFY when apply creates it — so an + // apply that adopts an existing hand-named environment writes into that + // one, rather than creating a second environment beside it. + envName: coolifyEnv, + serverUuid, + githubAppUuid, + destinationUuid: projectBinding?.destination_uuid, + s3DestinationUuid: ctx.binding.s3_destination, + backupSchedules, + }); + const { mutated } = await applyPlan(report, desired, exec); + console.log( + mutated.length === 0 + ? "no-op (clean)" + : `applied + redeployed: ${mutated.join(", ")}`, + ); + return { status: "applied", mutated }; +} + async function main(): Promise { const [command, ...rest] = process.argv.slice(2); if (command === "-h" || command === "--help" || command === "help") { @@ -561,14 +753,41 @@ async function main(): Promise { instance: { type: "string" }, "hostname-overlay": { type: "string" }, full: { type: "boolean", default: false }, + all: { type: "boolean", default: false }, }, }); const orgRepo = positionals[0]; const envName = values.env; - if (!orgRepo || !envName) { + // --all IS the target, so it replaces the positional rather than joining it. + if (!envName || (!orgRepo && !values.all)) { console.error(USAGE); return 2; } + // Before anything is read: --all names a fleet, and every coordinate below + // names ONE project's checkout, ONE project's Coolify name, ONE box's + // resource names. See SINGLE_PROJECT_COORDINATES — each refusal says which + // flag it was and what applying it fleet-wide would actually do. + if (values.all) { + const conflict = fleetConflict({ + "/": orgRepo, + "--path": values.path, + "--project": values.project, + "--environment": values.environment, + "--resource": values.resource, + "--hostname-overlay": values["hostname-overlay"], + }); + if (conflict) { + console.error(renderFleetConflict(command, conflict)); + return 2; + } + } + // A checkout cannot decide what prod runs. Refused here, before a state file + // is opened, and enforced again where it is actually honored (resolveCheckout) + // — same rule, same string, no second spelling of it. + if (refusesPathInProd({ env: envName, path: values.path })) { + console.error(PATH_IN_PROD_REFUSAL); + return 2; + } // Up front, before a clone or a decrypt or a single call: `apply` creates // resources under the MANIFEST's names, so an alias there would have to mean // "adopt the existing resource called X instead" — updating in place rather @@ -589,50 +808,23 @@ async function main(): Promise { return 2; } const stateDir = stateDirFrom(values.state); - const repoShort = orgRepo.split("/")[1]; - // The Coolify project name and the secrets-file key are different things - // that happen to default to the same string. Only the former is a name - // some other system chose: a project built by hand in the UI is called - // whatever someone typed. --project overrides that one, and nothing else — - // secrets stay keyed by the repo (a state-repo convention we own). - const projectName = values.project ?? repoShort; - // Exactly the same split, one level down. `--env` is OUR name for the - // environment: it selects the manifest block, the environments.yaml - // binding, the age key, the store path, the team to assert. `--environment` - // is THEIR name for it on the wire, and nothing else. Collapsing the two - // (as cast did until now) means a box built by hand in someone's UI gets to - // name our environment — and since apply creates the environment from this - // value, a legacy box's accident would be inherited by the new one forever. - const coolifyEnv = values.environment ?? envName; - const checkout = resolveCheckout(orgRepo, { - env: envName, - path: values.path, - }); - const secrets = decryptSecrets( - secretsFileFor(stateDir, repoShort, envName), - keyFileFor(envName), - ); - let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest( - checkout, - envName, - secrets, - ); - const bindings = loadBindings(join(stateDir, "environments.yaml")); + const bindingsPath = join(stateDir, "environments.yaml"); + const bindings = loadBindings(bindingsPath); const binding = bindings.environments[envName]; if (!binding) { console.error(`environment ${envName} not in environments.yaml`); return 2; } - assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns); - // Keyed by the REPO, not by --project: --project is the name Coolify's own - // UI happens to use for this project, and cast's state is keyed by the name - // WE own (same split as the secrets file — see the --project note above). - const projectBinding = projectBindingFor(bindings, envName, orgRepo); - if (values["hostname-overlay"]) { - desired = applyHostnameOverlay( - desired, - parseYaml(readFileSync(values["hostname-overlay"], "utf8")), + // The fleet, or the one repo that was named. `projectsIn` is the ONLY place + // "every project" comes from: a registry that does not list a project is a + // registry that has never heard of it, and cast does not go looking for one + // behind the operator's back. + const targets = values.all ? projectsIn(bindings, envName) : [orgRepo]; + if (values.all && targets.length === 0) { + console.error( + renderEmptyRegistry(command, envName, bindings, bindingsPath), ); + return 2; } const { instance, client } = openCoolify( stateDir, @@ -646,68 +838,85 @@ async function main(): Promise { // cheerfully report "everything is absent" and an unasserted `apply` // would then create all of it in the wrong team. The read is already // the lie; gate it, not just the write. + // + // Hoisted OUT of runProject deliberately, and it changes nothing about when + // it lands: the instance is one instance and the team is one team for the + // whole run, so asserting once here is asserting strictly before the FIRST + // project's first read. Re-asserting per project would be the same call with + // the same answer, N times. const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); - const mode = command === "apply" || values.full ? "full" : "structural"; - const lookup = await fetchLive(client, projectName, coolifyEnv); - // apply and diff take opposite (and both correct) positions on absence: - // apply is *allowed* to be the thing that brings a project into existence, - // so [] is a legitimate starting point. diff is only ever a claim about - // something that already exists — for it, absence is not an empty diff, it - // is the absence of anything to diff against, and reporting a full-create - // plan would launder that into a pass. See LiveLookup. - if (!lookup.found && command === "diff") { - console.error( - renderAbsentTarget(lookup, { - orgRepo, - overridden: values.project !== undefined, - envOverridden: values.environment !== undefined, - }), - ); - return 2; + const ctx: ProjectRunContext = { + command, + stateDir, + envName, + bindings, + binding, + client, + mode: command === "apply" || values.full ? "full" : "structural", + path: values.path, + projectOverride: values.project, + environmentOverride: values.environment, + resources: values.resource ?? [], + hostnameOverlay: values["hostname-overlay"], + }; + if (!values.all) { + const result = await runProject(ctx, targets[0]); + if (result.status === "absent") { + console.error(result.message); + return 2; + } + if (command === "diff") return result.status === "clean" ? 0 : 1; + return 0; } - const aliases = parseResourceAliases( - values.resource ?? [], - desired.map((d) => d.name), - ); - // Without this, a diff against a box that names things differently reports - // every manifest resource as "to create" and every live one as unknown — - // the D-237 lie by another route: a confident full-create plan that verified - // nothing, against a box that has all of it under other names. - const live = lookup.found ? aliasLive(lookup.live, aliases) : []; - if (mode === "full") { - for (const l of live) { - l.env = await fetchEnv(client, l); + const outcomes: ProjectOutcome[] = []; + for (const [i, repo] of targets.entries()) { + console.log(renderProjectHeading(repo, i + 1, targets.length)); + try { + const result = await runProject(ctx, repo); + if (result.status === "absent") { + console.error(result.message); + outcomes.push({ + repo, + status: "unreachable", + message: result.message, + }); + } else if (result.status === "applied") { + outcomes.push({ repo, status: "applied", mutated: result.mutated }); + } else { + outcomes.push({ repo, status: result.status }); + } + } catch (err) { + // Every way a project can fail to answer — a clone that will not clone, + // a manifest with no block for this environment, a store that will not + // decrypt, a 500 from Coolify — arrives here, and NONE of them is a skip. + // The single-project path lets these throw to main's handler; a fleet run + // cannot, or the first bad project would take the rest of the report with + // it (`diff`) or leave it un-summarized (`apply`). + const message = err instanceof Error ? err.message : String(err); + console.error(message); + outcomes.push({ repo, status: "unreachable", message }); + } + // `diff --all` runs every project to completion: stopping early hides the + // drift in the projects it never reached, and a partial read is exactly the + // report this flag exists to make impossible. `apply --all` does the + // opposite and stops — continuing to MUTATE a fleet after an unexplained + // failure is not a thing cast gets to do. The two dispositions differ + // because a read that continues costs nothing and a write that continues + // costs everything. + if ( + command === "apply" && + outcomes[outcomes.length - 1].status === "unreachable" + ) { + break; } } - const report = computeDiff(desired, live, mode, { - declaredDestination: projectBinding?.destination_uuid, - }); - console.log(renderDiff(report)); - if (command === "diff") return report.clean ? 0 : 1; - const serverUuid = await client.serverUuid(binding.server); - const githubAppUuid = await client.githubAppUuid( - githubAppNameFor(bindings, orgRepo), - ); - const exec = buildExecutor(client, { - projectName, - // The name the environment gets ON COOLIFY when apply creates it — so an - // apply that adopts an existing hand-named environment writes into that - // one, rather than creating a second environment beside it. - envName: coolifyEnv, - serverUuid, - githubAppUuid, - destinationUuid: projectBinding?.destination_uuid, - s3DestinationUuid: binding.s3_destination, - backupSchedules, - }); - const { mutated } = await applyPlan(report, desired, exec); console.log( - mutated.length === 0 - ? "no-op (clean)" - : `applied + redeployed: ${mutated.join(", ")}`, + command === "diff" + ? renderFleetDiff(envName, targets, outcomes) + : renderFleetApply(envName, targets, outcomes), ); - return 0; + return fleetExitCode(command, targets, outcomes); } if (command === "capture") { const { values, positionals } = parseArgs({ diff --git a/src/fleet.ts b/src/fleet.ts new file mode 100644 index 0000000..0020cf9 --- /dev/null +++ b/src/fleet.ts @@ -0,0 +1,320 @@ +import type { Bindings } from "./bindings.js"; + +// A fleet run is N project runs and ONE verdict — and the verdict is the part +// that has to be right. Every other command in cast answers a question about a +// project the operator named; `--all` answers a question about a set nobody +// enumerated by hand, which means the report's COVERAGE is as load-bearing as +// its content. "Nothing to report" and "I looked at nothing" must never render +// the same, so a project is only ever one of these four things, and the one +// that does not exist is "skipped". +export type ProjectOutcome = + | { repo: string; status: "clean" } + | { repo: string; status: "drift" } + | { repo: string; status: "applied"; mutated: string[] } + // Everything that stopped cast from producing a result for a project it was + // told exists: the clone failing, no manifest block for this environment, an + // absent or undecryptable secret store, an absent Coolify project or + // environment, any HTTP error. They collapse into one status deliberately — + // downstream, the only thing that matters about all of them is that this + // project WAS NOT READ, and every one of them is fatal to the fleet. + | { repo: string; status: "unreachable"; message: string }; + +// The headline of an unreachable project, for the summary block. The full +// message (multi-line, and normally very much worth reading) is printed in the +// project's own section as the run reaches it; repeating it whole at the bottom +// would bury the aggregate under the detail it is meant to summarize. +function headline(message: string): string { + return message.split("\n")[0]; +} + +export function renderProjectHeading( + repo: string, + index: number, + total: number, +): string { + const label = `[${index}/${total}] ${repo}`; + return `\n── ${label} ${"─".repeat(Math.max(3, 74 - label.length))}`; +} + +function coverage(registered: string[], outcomes: ProjectOutcome[]) { + const of = (status: ProjectOutcome["status"]) => + outcomes.filter((o) => o.status === status).map((o) => o.repo); + const visited = new Set(outcomes.map((o) => o.repo)); + return { + clean: of("clean"), + drift: of("drift"), + applied: of("applied"), + unreachable: outcomes.filter( + (o): o is Extract => + o.status === "unreachable", + ), + // Registered, and never even attempted. Only `apply` can produce these (it + // stops at the first failure); for `diff` this is always empty, because a + // read that stops early hides the drift in the projects it never reached. + notReached: registered.filter((r) => !visited.has(r)), + }; +} + +const list = (repos: string[]): string => repos.join(", ") || "—"; + +// The aggregate a human reads to decide whether to trust the run — so it leads +// with COUNTS OF PROJECTS, not counts of changes. A drifted project and an +// unreadable one are both "not clean", but only one of them is a diff result: +// the other is a hole in the report, and a report with a hole in it is not a +// clean fleet however green the projects around the hole were. +export function renderFleetDiff( + envName: string, + registered: string[], + outcomes: ProjectOutcome[], +): string { + const { clean, drift, unreachable, notReached } = coverage( + registered, + outcomes, + ); + const read = clean.length + drift.length; + const lines = [ + "", + `fleet diff — ${envName}`, + "", + ` registered: ${registered.length} (${list(registered)})`, + ` read: ${read} of ${registered.length}`, + ` clean: ${clean.length} ${list(clean)}`, + ` drift: ${drift.length} ${list(drift)}`, + ` UNREACHABLE: ${unreachable.length} ${list(unreachable.map((u) => u.repo))}`, + ]; + for (const u of unreachable) { + lines.push(` ${u.repo}: ${headline(u.message)}`); + } + if (notReached.length > 0) { + // Cannot happen for `diff` (it runs every project to completion), and said + // out loud anyway: if it ever did, the fleet's coverage would be a lie and + // the exit code below would be a 2. Silence here would hide the one thing + // this report is for. + lines.push(` NOT REACHED: ${notReached.length} ${list(notReached)}`); + } + lines.push(""); + if (unreachable.length > 0 || notReached.length > 0) { + lines.push( + `${unreachable.length + notReached.length} of ${registered.length} registered project(s) could not be read.`, + "", + "A fleet report is worth exactly its coverage. An unread project is not a clean", + "one — it is a project cast has nothing to say about, and a silently skipped", + "project reads exactly like a clean one (#12/#18/#22, at fleet scale). So this", + "run is a FAILURE, not a diff result: what it read stands, but the state of this", + "environment as a whole is UNKNOWN until every registered project answers.", + ); + } else if (drift.length > 0) { + lines.push( + `all ${registered.length} registered project(s) were read; ${drift.length} ${ + drift.length === 1 ? "has" : "have" + } drift.`, + ); + } else { + lines.push( + `all ${registered.length} registered project(s) were read, and every one is clean.`, + ); + } + return lines.join("\n"); +} + +export function renderFleetApply( + envName: string, + registered: string[], + outcomes: ProjectOutcome[], +): string { + const { applied, unreachable, notReached } = coverage(registered, outcomes); + const changed = outcomes.filter( + (o): o is Extract => + o.status === "applied" && o.mutated.length > 0, + ); + const lines = [ + "", + `fleet apply — ${envName}`, + "", + ` registered: ${registered.length} (${list(registered)})`, + ` applied: ${applied.length} of ${registered.length} ${list(applied)}`, + ]; + for (const c of changed) { + lines.push(` ${c.repo}: ${c.mutated.join(", ")}`); + } + if (unreachable.length > 0) { + lines.push( + ` FAILED: ${list(unreachable.map((u) => u.repo))}`, + ...unreachable.map((u) => ` ${headline(u.message)}`), + ); + } + if (notReached.length > 0) { + lines.push(` not reached: ${notReached.length} ${list(notReached)}`); + } + lines.push(""); + if (unreachable.length === 0 && notReached.length === 0) { + lines.push( + `all ${registered.length} registered project(s) applied.`, + ...(changed.length === 0 + ? ["nothing changed — the fleet already matched its manifests."] + : []), + ); + return lines.join("\n"); + } + lines.push( + "STOPPED at the first failure, and deliberately: continuing to mutate a fleet after", + "an unexplained failure is not a thing cast gets to do — the next project's apply", + "would be a guess about whether the last one broke something it depends on.", + "", + notReached.length > 0 + ? `The ${notReached.length} project(s) listed as "not reached" were NOT touched. Resolve the failure` + : "Resolve the failure", + "and re-run: `apply` is idempotent, so re-running over the projects that already", + "applied is a no-op.", + ); + return lines.join("\n"); +} + +// The whole point of the flag, expressed as a number. +// +// diff: 0 every registered project was READ, and every one is clean +// 1 every registered project was read, and at least one has drift +// 2 a registered project could not be read — which OUTRANKS drift, +// because it is not a diff result at all: it is the absence of one. +// apply: 0 every registered project applied +// 2 anything else +// +// The default is 2, not 0: a coverage gap this function does not recognize must +// fail, not pass. An exit code is the only part of this report that CI reads. +export function fleetExitCode( + verb: "apply" | "diff", + registered: string[], + outcomes: ProjectOutcome[], +): number { + const { clean, drift, applied, unreachable, notReached } = coverage( + registered, + outcomes, + ); + if (unreachable.length > 0 || notReached.length > 0) return 2; + if (verb === "apply") return applied.length === registered.length ? 0 : 2; + if (clean.length + drift.length !== registered.length) return 2; + return drift.length > 0 ? 1 : 0; +} + +// An empty registry REFUSES. It does not report "0 projects, clean". +// +// `projectsIn` answers `[]` for a state file with no `projects:` block, and for +// one whose registry names no project in this environment. Both are the same +// thing to a fleet run: NOTHING TO ITERATE — and a fleet run over nothing prints +// exactly what a fleet run over a clean fleet prints. That equivalence is the +// entire failure this feature exists to prevent, so it is a refusal, and the +// refusal says what was looked for and what to write instead. +export function renderEmptyRegistry( + verb: "apply" | "diff", + envName: string, + bindings: Bindings, + bindingsPath: string, +): string { + const registry = bindings.projects; + const elsewhere = Object.entries(registry ?? {}).map( + ([slug, project]) => `${slug} (${project.environments.join(", ")})`, + ); + const found = !registry + ? [ + " found: no `projects:` block at all — this state file predates the", + " registry, so it lists no projects anywhere", + ] + : elsewhere.length > 0 + ? [ + " found: a registry, but nothing registered for this environment:", + ...elsewhere.map((e) => ` ${e}`), + ] + : [" found: a `projects:` block with no projects in it"]; + return [ + `refusing to ${verb} --all: no projects are registered for "${envName}"`, + "", + ` looked for: projects./.environments containing "${envName}"`, + ` in: ${bindingsPath}`, + ...found, + "", + "--all iterates the registry — the list of which projects exist. An EMPTY list is", + "not an empty fleet; it is an unanswered question. A run over no projects prints", + 'exactly what a run over a clean fleet prints, and "0 projects, clean" is the one', + "sentence this flag exists to make impossible.", + "", + "Register what deploys here (or name the one repo you mean, without --all):", + "", + " projects:", + " /:", + ` environments: [${envName}]`, + ].join("\n"); +} + +// The coordinates that name ONE project, each with the reason it cannot mean +// anything across a fleet. Every one of them is a name someone else chose for a +// single project — one checkout, one Coolify project, one box's resource names — +// and none of them is true of the project next to it. Fleet-wide they are +// meaningless at best and, on `apply`, actively dangerous. +// +// Insertion order is the reporting order: the positional first, because passing +// a repo AND --all is the most likely way in here. +const SINGLE_PROJECT_COORDINATES: Record = { + "/": [ + "A repo positional names the ONE project to act on. --all says: every project the", + "registry lists for this environment. Passing both asks cast to guess which of the", + "two you meant — and the wrong guess is either a fleet-wide act you did not ask", + "for, or a fleet you thought you covered and did not.", + ], + "--path": [ + "--path is ONE project's local checkout. Fleet-wide it would resolve every project", + "to the same working tree, and cast would diff one repo's manifest against every", + "other project on the box — reporting each of them as an unrecognizable mess of", + "creates and orphans.", + ], + "--project": [ + "--project is the name ONE project has on Coolify (the coordinate for a project", + "somebody built by hand in the UI). Fleet-wide it would point every project in the", + "registry at that SAME Coolify project: on `diff` that is a false report, and on", + "`apply` it is every manifest in the fleet written into one project.", + ], + "--environment": [ + "--environment is the name ONE project's environment has on the wire. The projects", + "in a fleet need not share it — a box that calls one of them `production` says", + "nothing whatsoever about the next one — so fleet-wide it is a guess applied to", + "projects that never agreed to it.", + ], + "--resource": [ + "--resource maps ONE project's manifest names onto the names some box gave the same", + "resources. Its left-hand side is validated against that project's manifest, so", + "across a fleet it is either an error or, worse, an alias that happens to match a", + "different project's resource and quietly diffs the wrong thing.", + ], + "--hostname-overlay": [ + "--hostname-overlay names applications in ONE project's manifest (an unknown name is", + "an error — that is the guard that makes it safe). It cannot be true of a second", + "project's manifest, and a fleet cutover is not one file's job.", + ], +}; + +// Which single-project coordinate was passed alongside --all, if any. `undefined` +// means the invocation is a legitimate fleet run. +export function fleetConflict( + given: Record, +): string | undefined { + for (const flag of Object.keys(SINGLE_PROJECT_COORDINATES)) { + const value = given[flag]; + if (value === undefined) continue; + if (Array.isArray(value) && value.length === 0) continue; + return flag; + } + return undefined; +} + +export function renderFleetConflict( + verb: "apply" | "diff", + flag: string, +): string { + return [ + `refusing to ${verb}: --all cannot be combined with ${flag}`, + "", + ...(SINGLE_PROJECT_COORDINATES[flag] ?? []), + "", + `Act on one project (name its /, and keep ${flag}), or act on the whole`, + "environment (--all, and drop it). Not both.", + ].join("\n"); +} diff --git a/src/resolve.ts b/src/resolve.ts index 4ad6c51..43bb888 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -132,17 +132,32 @@ export function cloneFailureMessage( ].join("\n"); } +// Holds for every verb that reads a manifest (apply, diff, capture, inventory): +// a feature-branch checkout must not be able to decide what prod runs, nor which +// secret names land in prod's store. +// +// The rule is a value, not only a throw inside resolveCheckout, because the CLI +// refuses this combination UP FRONT — before it opens a state file, a store or a +// Coolify. A flag pairing that can never be honored must not need the rest of the +// invocation to be well-formed in order to be caught (it used to be caught late, +// and only by accident of resolveCheckout running before the bindings load). One +// rule, one string, two call sites — never two spellings of the same refusal. +export const PATH_IN_PROD_REFUSAL = + "refuses --path with --env prod: prod always reads the default branch"; + +export function refusesPathInProd(opts: { + env: string; + path?: string; +}): boolean { + return opts.path !== undefined && opts.env === "prod"; +} + export function resolveCheckout( orgRepo: string, opts: { env: string; path?: string }, ): string { - if (opts.path && opts.env === "prod") { - // Holds for every verb that reads a manifest (apply, and now capture): a - // feature-branch checkout must not be able to decide what prod runs, nor - // which secret names land in prod's store. - throw new Error( - "refuses --path with --env prod: prod always reads the default branch", - ); + if (refusesPathInProd(opts)) { + throw new Error(PATH_IN_PROD_REFUSAL); } if (opts.path) return opts.path; const dir = mkdtempSync(join(tmpdir(), "infra-checkout-")); diff --git a/test/fleet-cli.test.ts b/test/fleet-cli.test.ts new file mode 100644 index 0000000..01ea316 --- /dev/null +++ b/test/fleet-cli.test.ts @@ -0,0 +1,427 @@ +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 }; +const stubs: Stub[] = []; + +async function stubCoolify( + behavior: Partial> = {}, +): Promise { + 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((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((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//.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[]]> = [ + ["/", ["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"); + }); +}); diff --git a/test/fleet.test.ts b/test/fleet.test.ts new file mode 100644 index 0000000..8f3252c --- /dev/null +++ b/test/fleet.test.ts @@ -0,0 +1,214 @@ +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({ "/": "heavy-duty/alpha" })).toBe( + "/", + ); + }); + + 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(); + }); +});