diff --git a/README.md b/README.md index 0f43a88..a183cf5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ environments.yaml # bindings: the team each env's token must belon # which server it deploys onto, the S3 destination, # GitHub App name, guards — and, per project, # the destination it deploys onto + its smoke target + # …plus `projects:`, the registry: which projects + # exist, and in which environments secrets/..env.age # age-encrypted values for the ${…} placeholders .coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit) .coolify/.env # …the same, for a NAMED instance (see below) @@ -62,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] @@ -77,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** @@ -357,6 +364,120 @@ own (it hangs off a project) and no API path scopes by one: **Coolify environments are an organizational construct, not an auth boundary.** The team is the only boundary there is, so it is the one cast asserts. +## The registry: which projects exist + +`environments.yaml` says where things deploy *to*, and how a project you have +already named is placed once it is there. Until the `projects:` block, nothing in +it said **which projects exist at all** — "every project" was a thing the +operator remembered: + +```yaml +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +``` + +Keyed by the **full `/` slug**, and the key *is* the repo — there is +no `repo:` field inside, because a second place to write the same string is a +second place for it to be wrong. Unlike `github_apps`, a bare `` key is +**refused** rather than resolved: this block is new, so it has no state files in +the wild to keep working, and a bare `` is unique only *within* an org — +which is exactly why it is not a key. `environments:` lists **our** environment +names (the values `--env` takes), never Coolify's. + +The block is optional; a state file written before it loads unchanged. + +**It has to be true, so cast checks that it is — at parse time, for every verb.** +Two ways it could quietly stop being true, both refused: + +- an environment name that no `environments:` block defines (a typo). The project + is real and its environment imaginary, so a fleet run visits nothing for it, + reports nothing, and exits clean. +- an `environments..projects.` binding — a destination, a smoke target + — in an environment the registry does not register that project for. The two + blocks then describe two different fleets: state real enough for a direct + `cast apply` to use, invisible to every fleet run. (Checked only when + `projects:` is present.) + +Both refusals defend one failure: **a silently skipped project reads exactly like +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 (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 409bd34..aa81ec1 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -158,6 +158,131 @@ softened by an implementation detail): refuses `--path` combined with `--env prod`: prod always reads the default branch, so a feature-branch checkout can never reach it. +## The registry (`projects:`) + +The top-level `projects:` block is the list of **which projects exist**, and in +which environments. It is the only place that says so: `environments:` says where +things deploy to, `environments..projects.` says how an already-named +project is placed, `github_apps` says how to clone one you have already named. + +```yaml +projects: + heavy-duty/incubator: + environments: [prod, staging] +``` + +- **Keyed by the full `/` slug, with no bare-`` fallback.** The + key is the repo; there is no `repo:` field. `github_apps` and + `environments..projects` accept a bare key because state files in the wild + are written that way; this block is new and has none, so it requires the slug — + a bare `` is unique only *within* an org. +- **`environments:` names OUR environments** — the keys of the `environments:` + block, the values `--env` takes — never Coolify's. Non-empty. +- **Optional.** A state file with no `projects:` block loads unchanged, and + `projectsIn` reports `[]` for every environment. + +**Validated at parse time, so every verb refuses a registry that lies.** Two +refusals, both defending the same failure — *a silently skipped project reads +exactly like a clean one*, which makes silence, the most common report there is, +ambiguous: + +1. **An environment that does not exist** (a typo in `projects..environments`) + is an error naming the unknown environment and listing the known ones. Left + alone, the project would be registered into an environment no command can + visit: a fleet run skips it, reports nothing, exits clean. +2. **A binding the registry does not register.** Every + `environments..projects.` key must be a project the registry + registers *for that environment*. Otherwise the two blocks describe two + different fleets: a `destination_uuid` or `smoke_target` real enough for a + direct `cast apply --env ` to act on, and invisible to every + fleet run over that environment. Enforced **only when `projects:` is + 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`, 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 c414cc0..66be606 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -58,6 +58,32 @@ const ProjectBindingSchema = z export type ProjectBinding = z.infer; +// One project in the registry — the top-level `projects:` block, which is the +// list of what EXISTS. Nothing else in this file says that: `environments:` +// says where things are deployed to, `environments..projects.` says +// how one project is placed once you already know it is there, and +// `github_apps` says how to clone one you have already named. "Every project" +// was, until this block, a thing the operator remembered. +// +// Two things need the list, and neither can be built without it: fleet +// operations (`cast diff --all`, #26 — iterating "every project in this +// environment") and rebuild-from-state (#27 — a Coolify restored from the state +// repo, which cannot even be attempted without knowing what was on it). +const RegisteredProjectSchema = z + .object({ + // OUR environment names — the values `--env` takes, the keys of the + // `environments:` block above — never Coolify's. The distinction is the same + // one `--env` vs `--environment` draws everywhere else in cast. + // + // Non-empty: a project registered into no environment is not a registration, + // it is a line of YAML that reads like one. It would be skipped by every + // fleet run silently. + environments: z.array(z.string().min(1)).nonempty(), + }) + .strict(); + +export type RegisteredProject = z.infer; + const BindingsSchema = z .object({ environments: z.record( @@ -100,6 +126,22 @@ const BindingsSchema = z }) .strict(), ), + // THE REGISTRY: which projects exist at all, keyed by the full `/` + // slug. The key IS the repo — there is no `repo:` field inside, because a + // second place to write the same string is a second place for it to be + // wrong. + // + // Full slug REQUIRED, with no bare-`` fallback — the one place in this + // file where that fallback does not exist. `github_apps` and + // `environments..projects` carry one because they predate the lesson + // (#12) and there are state files in the wild keyed the old way; this block + // is new, has no such files, and so gets to be right from the start. A bare + // `` is unique only *within* an org, which is precisely why it is not + // a key. + // + // Optional: a state file written before the registry existed keeps loading + // untouched, and `projectsIn` answers `[]` for it. + projects: z.record(RegisteredProjectSchema).optional(), // Keyed by the repo the App clones for. Prefer the FULL `/` // slug; a bare `` key still resolves (see githubAppNameFor) so // existing state files keep working. @@ -112,7 +154,136 @@ const BindingsSchema = z // cannot distinguish prod's app from staging's either. smoke_target: z.string().optional(), }) - .strict(); + .strict() + // The registry only earns its keep if it is TRUE. Every check here defends the + // same failure: a project that a fleet run never visits, because a fleet run + // that skips a project prints exactly what a fleet run over a clean project + // prints — nothing. Silence is the one report that must never be ambiguous, so + // these are parse-time errors (every verb loads bindings, so every verb refuses + // a registry that lies) rather than warnings some command might print. + .superRefine((bindings, ctx) => { + const registry = bindings.projects; + if (!registry) return; + + const knownEnvs = Object.keys(bindings.environments); + const knownEnvList = knownEnvs.join(", ") || "(none)"; + + for (const [slug, project] of Object.entries(registry)) { + // A key with no `/` is not a repo. See the schema note above: no fallback. + if (!slug.includes("/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", slug], + message: [ + `projects["${slug}"] is not a repo — a registry key has no meaning without its org`, + "", + ` found: projects["${slug}"]`, + " wanted: a full / slug", + "", + "A bare is unique only *within* an org: heavy-duty/incubator and", + "acme/incubator collapse onto one entry, and the registry then claims one", + "project where there are two. Unlike github_apps, this block is new and has", + "no legacy state files to support, so there is no bare- fallback.", + "", + " projects:", + ` /${slug}:`, + ` environments: [${project.environments.join(", ")}]`, + ].join("\n"), + }); + } + + // Every environment named here must be one that actually exists. A typo + // makes the project real but its environment imaginary — so `--all` visits + // nothing for it, reports nothing about it, and exits clean. + for (const envName of project.environments) { + if (!(envName in bindings.environments)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["projects", slug, "environments"], + message: [ + `projects["${slug}"] is registered in environment "${envName}", which does not exist`, + "", + ` registered: projects["${slug}"].environments → ${project.environments.join(", ")}`, + ` known envs: ${knownEnvList}`, + "", + "An environment nothing defines is one that no command can visit: a fleet", + "run would skip this project, and a silently skipped project reads exactly", + "like a clean one. Fix the name, or declare the environment:", + "", + " environments:", + ` ${envName}:`, + " server: ", + " team: { id: , name: }", + ].join("\n"), + }); + } + } + } + + // The other direction, and the one that rots quietly: a project carrying + // per-environment state (destination_uuid, smoke_target — #21) in an + // environment the registry does not register it for. That state is then real + // enough to be used by a direct `cast apply --env ` and invisible + // to every fleet run — the two blocks describing two different fleets. + // + // Only checked when `projects:` is present, so state files written before the + // registry keep loading exactly as they did. + for (const [envName, env] of Object.entries(bindings.environments)) { + for (const key of Object.keys(env.projects ?? {})) { + const entry = registry[key]; + if (entry?.environments.includes(envName)) continue; + + // The likeliest cause, worth saying out loud: a legacy bare- key + // (which projectBindingFor still resolves) under a registry that is + // correctly keyed by slug. The fix is a rename, not a registration. + const slugFor = key.includes("/") + ? undefined + : Object.keys(registry).find((s) => s.endsWith(`/${key}`)); + + const cause = slugFor + ? [ + ` registry has: projects["${slugFor}"]`, + "", + "The binding uses the legacy bare- key. The registry is keyed by the", + `full slug, so rename it to match — environments.${envName}.projects["${slugFor}"].`, + ] + : entry + ? [ + ` registered for: ${entry.environments.join(", ")}`, + "", + "Register the project in this environment, or drop the binding — state that", + "no fleet run will ever visit is state that stops being true without anyone", + "finding out:", + "", + " projects:", + ` ${key}:`, + ` environments: [${[...entry.environments, envName].join(", ")}]`, + ] + : [ + ` registry has: ${Object.keys(registry).join(", ") || "(nothing)"}`, + "", + "The project is not in the registry at all, so no fleet run will ever visit", + "it — while this binding says it is deployed here. Register it, or drop the", + "binding:", + "", + " projects:", + ` ${key}:`, + ` environments: [${envName}]`, + ]; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["environments", envName, "projects", key], + message: [ + `environments.${envName}.projects["${key}"] is bound in an environment the registry does not register it for`, + "", + ` bound at: environments.${envName}.projects["${key}"]`, + ...cause, + ].join("\n"), + }); + } + } + }); export type Bindings = z.infer; @@ -188,6 +359,32 @@ export function smokeTargetFor( return undefined; } +// The `/` slugs registered for one environment — the list a fleet +// operation iterates (`cast diff --all`, #26) and a rebuild reads (#27). +// +// SORTED, deliberately: the order of keys in a YAML file is an accident of who +// typed what when, and a fleet run's output — which a human reads top to bottom, +// and CI diffs — must not reshuffle because someone appended a project. The +// 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 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 []; + return Object.entries(registry) + .filter(([, project]) => project.environments.includes(envName)) + .map(([slug]) => slug) + .sort(); +} + export function loadBindings( path: string, opts: { overrideText?: string } = {}, @@ -195,7 +392,20 @@ export function loadBindings( const text = opts.overrideText ?? readFileSync(path, "utf8"); const result = BindingsSchema.safeParse(parse(text)); if (!result.success) { - throw new Error(`invalid bindings ${path}: ${result.error.message}`); + // Zod's own `.message` is the entire issue array as JSON — which renders the + // refusals above as one long line of `\n` escapes, i.e. throws away the part + // of them that was worth writing. Render the issues instead. + const detail = result.error.issues + .map((issue) => { + // A multi-line message is one WE wrote: it already names the path, the + // cause, and the YAML to write. A one-line message is zod's ("Required"), + // and is useless without the path it happened at. + if (issue.message.includes("\n")) return issue.message; + const where = issue.path.map(String).join("."); + return where ? `${where}: ${issue.message}` : issue.message; + }) + .join("\n\n"); + throw new Error(`invalid bindings ${path}:\n\n${detail}`); } return result.data; } 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/bindings.test.ts b/test/bindings.test.ts index e9cace5..b38c3c0 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -5,6 +5,7 @@ import { githubAppNameFor, loadBindings, projectBindingFor, + projectsIn, smokeTargetFor, } from "../src/bindings.js"; @@ -223,3 +224,233 @@ github_apps: {} ).toThrow(/invalid bindings/); }); }); + +// The registry: the list of which projects exist at all. Everything it is FOR +// (fleet iteration, rebuild-from-state) depends on it being true, and the way it +// stops being true is silent — see the refusals below. +describe("the project registry", () => { + const twoEnvs = ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + staging: + server: staging-box + team: { id: 0, name: Root Team } +`; + + it("registers projects per environment, keyed by the full slug", () => { + const b = loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +github_apps: {} +`, + }); + expect(b.projects).toEqual({ + "heavy-duty/incubator": { environments: ["prod", "staging"] }, + "acme/client-site": { environments: ["prod"] }, + }); + }); + + describe("projectsIn", () => { + const b = loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, staging] + acme/client-site: + environments: [prod] +github_apps: {} +`, + }); + + // Sorted, not file-order: a fleet run's output is read by a human and diffed + // by CI, and must not reshuffle because someone appended a project. + it("gives an environment's projects, sorted", () => { + expect(projectsIn(b, "prod")).toEqual([ + "acme/client-site", + "heavy-duty/incubator", + ]); + }); + + it("gives only the projects registered for that environment", () => { + expect(projectsIn(b, "staging")).toEqual(["heavy-duty/incubator"]); + }); + + it("is empty for an environment no project is registered in", () => { + expect(projectsIn(b, "nowhere")).toEqual([]); + }); + }); + + // The refusal the issue is actually about. A typo'd environment name makes the + // project real and its environment imaginary: `cast diff --all` visits nothing + // for it, reports nothing, and exits clean — and a silently skipped project + // reads exactly like a clean one. + it("refuses an environment that does not exist, naming the ones that do", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod, stagng] +github_apps: {} +`, + }); + expect(err).toThrow(/environment "stagng", which does not exist/); + expect(err).toThrow(/known envs:\s+prod, staging/); + }); + + // The other direction, and the one that rots quietly: per-environment state + // (#21) sitting in an environment the registry does not register the project + // for. The two blocks then describe two different fleets. + it("refuses a binding in an environment the registry does not register", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + staging: + server: staging-box + team: { id: 0, name: Root Team } + projects: + heavy-duty/incubator: + destination_uuid: dest-abc +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow( + /environments\.staging\.projects\["heavy-duty\/incubator"\]/, + ); + expect(err).toThrow(/registered for:\s+prod/); + }); + + it("refuses a binding for a project the registry does not carry at all", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + acme/client-site: + destination_uuid: dest-client +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/environments\.prod\.projects\["acme\/client-site"\]/); + expect(err).toThrow(/registry has:\s+heavy-duty\/incubator/); + }); + + // A legacy bare- binding key (projectBindingFor still resolves one) under + // a slug-keyed registry is drift with an obvious fix — say which fix. + it("tells a legacy bare- binding key which slug to rename to", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + incubator: + smoke_target: core +projects: + heavy-duty/incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/registry has:\s+projects\["heavy-duty\/incubator"\]/); + expect(err).toThrow(/legacy bare- key/); + }); + + // No fallback here, unlike github_apps: this block is new, so it has no state + // files in the wild to keep working, and a bare is unique only within an + // org — which is exactly why it is not a key. + it("refuses a bare registry key — the org is not optional", () => { + const err = () => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + incubator: + environments: [prod] +github_apps: {} +`, + }); + expect(err).toThrow(/projects\["incubator"\] is not a repo/); + expect(err).toThrow(/full \/ slug/); + }); + + // A project registered into nothing is a line of YAML that reads like a + // registration and is skipped by every fleet run. + it("refuses a project registered into no environment", () => { + expect(() => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [] +github_apps: {} +`, + }), + ).toThrow(/invalid bindings/); + }); + + it("rejects an unknown key inside a registry entry", () => { + expect(() => + loadBindings("environments.yaml", { + overrideText: `${twoEnvs} +projects: + heavy-duty/incubator: + environments: [prod] + repo: heavy-duty/incubator +github_apps: {} +`, + }), + ).toThrow(/invalid bindings/); + }); + + // Back-compat: the registry is optional, and every state file written before it + // existed has no `projects:` block. Such a file loads unchanged — including its + // per-environment bindings, which are NOT checked against a registry that is + // not there. + describe("with no registry at all", () => { + const b = loadBindings("environments.yaml", { + overrideText: ` +environments: + prod: + server: shared-box + team: { id: 0, name: Root Team } + projects: + heavy-duty/incubator: + destination_uuid: dest-abc + smoke_target: core +github_apps: {} +`, + }); + + it("loads, and keeps its per-environment bindings working", () => { + expect(b.projects).toBe(undefined); + expect( + projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid, + ).toBe("dest-abc"); + }); + + it("has no projects registered in any environment", () => { + expect(projectsIn(b, "prod")).toEqual([]); + }); + }); +}); 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(); + }); +});