feat: --all — every project in an environment, and a report that says so (#26)

Every cast verb was single-project, so "do this to the whole instance" was a
shell loop the operator wrote from memory — and the project they forgot is the
one that drifted. `cast diff --env prod --all` and `cast apply --env prod --all`
iterate the registry (#25) instead.

The bulk of this is a refactor: the apply/diff block in cli.ts was one long
inline body, and it is now `runProject` — checkout → secrets → desired →
bindings → live → diff → optionally apply. Both the single-repo path and the
`--all` loop call it, so there is exactly ONE implementation of what a project
run is. A second, parallel fleet path is how the two would drift, and drift is
the subject of this tool. `openCoolify` and the team assert are hoisted out of
it: one --env means one instance and one team, so asserting once still lands
strictly before the FIRST project's first read — the read is already the lie.

Fails closed on the aggregate. A registered project cast cannot reach is an
ERROR, never a skip: the clone failing, no manifest block for this environment,
an absent or undecryptable store, an absent Coolify project/environment, any
HTTP error. A silently skipped project reads exactly like a clean one — #12/#18/
#22 at fleet scale — so the report leads with COVERAGE (registered / read /
clean / drifted / unreachable), and:

  diff --all   0  every registered project was READ, and every one is clean
               1  every one was read, and at least one has drift
               2  a project could not be read — outranking drift, because an
                  unreadable project is not a diff result but the absence of one
  apply --all  0  every registered project applied; non-zero otherwise

`diff --all` runs every project to completion (stopping hides the drift in the
projects it never reached); `apply --all` STOPS at the first failure and names
what it applied and what it did not touch (continuing to mutate a fleet after an
unexplained failure is not a thing cast gets to do).

Two refusals. An empty or absent registry refuses rather than printing
"0 projects, clean" — an empty fleet reading as a clean fleet is the whole
failure this is against; the message distinguishes an unmigrated state file from
a registry pointed elsewhere and prints the YAML to write. And `--all` is
mutually exclusive with the repo positional and with every single-project
coordinate (--path, --project, --environment, --resource, --hostname-overlay):
each names ONE project's checkout, ONE project's Coolify name, ONE box's
resource names, and `--project X` across a fleet would point every project at
the same Coolify project — a false report on diff, and on apply every manifest
in the fleet written into one project.

Also: `projectsIn`'s doc-comment guessed that `[]` made a fleet verb over an
unmigrated state file "a clean no-op rather than a crash". It is precisely
backwards, and now says so. And the --path/--env-prod refusal is hoisted to the
CLI's up-front flag validation (one rule, one string, two call sites in
resolve.ts) — it used to be caught only by accident of resolveCheckout running
before the bindings load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-13 20:21:27 +00:00
parent 18660041f9
commit 8dde6dbc05
8 changed files with 1447 additions and 106 deletions

View file

@ -64,7 +64,9 @@ Pass it with `--state <dir>`, or set `CAST_STATE`. Defaults to the cwd.
```sh
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast apply --env <env> --all # no repo: EVERY registered project
cast diff <org>/<repo> --env <env> [--full]
cast diff --env <env> --all [--full] # no repo: EVERY registered project
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
@ -79,6 +81,9 @@ cast team [--env <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

View file

@ -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 <env> --all` and `cast apply --env <env> --all` act on **every
project the registry lists for that environment**, in place of the `<org>/<repo>`
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

View file

@ -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 [];

View file

@ -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 <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--hostname-overlay <file>]
cast apply --env <env> --all # no repo: EVERY registered project
cast diff <org>/<repo> --env <env> [--full] [--project <name>] [--environment <name>]
cast diff --env <env> --all [--full] # no repo: EVERY registered project
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--generated <NAME>] [--override <NAME>] [--force]
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>]
cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance
@ -99,6 +115,20 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
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 <NAME> force NAME to the \`pending-coolify-generated\` placeholder,
@ -541,6 +571,168 @@ async function confirmCapture(envName: string): Promise<boolean> {
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 <repo>` 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<ProjectResult> {
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<number> {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
@ -561,14 +753,41 @@ async function main(): Promise<number> {
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({
"<org>/<repo>": 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<number> {
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,69 +838,86 @@ async function main(): Promise<number> {
// 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,
}),
);
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;
}
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 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(", ")}`,
);
if (command === "diff") return result.status === "clean" ? 0 : 1;
return 0;
}
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;
}
}
console.log(
command === "diff"
? renderFleetDiff(envName, targets, outcomes)
: renderFleetApply(envName, targets, outcomes),
);
return fleetExitCode(command, targets, outcomes);
}
if (command === "capture") {
const { values, positionals } = parseArgs({
args: rest,

320
src/fleet.ts Normal file
View file

@ -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<ProjectOutcome, { status: "unreachable" }> =>
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<ProjectOutcome, { status: "applied" }> =>
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.<org>/<repo>.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:",
" <org>/<repo>:",
` 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<string, string[]> = {
"<org>/<repo>": [
"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, unknown>,
): 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 <org>/<repo>, and keep ${flag}), or act on the whole`,
"environment (--all, and drop it). Not both.",
].join("\n");
}

View file

@ -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-"));

427
test/fleet-cli.test.ts Normal file
View file

@ -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<void> };
const stubs: Stub[] = [];
async function stubCoolify(
behavior: Partial<Record<string, Behavior>> = {},
): Promise<Stub> {
const of = (repo: string): Behavior => behavior[repo] ?? "clean";
const hits: string[] = [];
const server = createServer((req, res) => {
const path = (req.url ?? "").replace("/api/v1", "");
hits.push(path);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/servers") return json([{ uuid: "s1", name: "fleet-box" }]);
if (path === "/github-apps")
return json([{ uuid: "g1", name: "hdb-coolify" }]);
if (path === "/projects") {
return json(
REPOS.filter((r) => of(r) !== "absent").map((r) => ({
uuid: `p-${r}`,
name: r,
})),
);
}
for (const repo of REPOS) {
if (path === `/projects/p-${repo}/staging`) {
if (of(repo) === "error") {
res.writeHead(500);
return res.end("boom");
}
return json({
applications: [
{
name: "core",
uuid: `a-${repo}`,
git_repository: `heavy-duty/${repo}`,
git_branch:
of(repo) === "drift" ? "someones-feature-branch" : "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: `http://${repo}.example.com`,
destination_id: 1,
},
],
});
}
if (path === `/applications/a-${repo}/envs`) return json([]);
}
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
const manifest = (repo: string) => `project: ${repo}
environments:
staging:
applications:
core:
source: { repo: heavy-duty/${repo}, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://${repo}.example.com"]
`;
// A state dir + three clonable product repos. `registry` is the knob: which
// slugs the `projects:` block registers for staging — undefined writes no
// `projects:` block at all (a state file from before the registry existed).
function fixture(
url: string,
opts: { registry?: string[]; registryEnv?: string } = {},
) {
const root = mkdtempSync(join(tmpdir(), "cast-fleet-"));
for (const repo of REPOS) {
const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(join(dir, ".infra", "manifest.yaml"), manifest(repo));
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: dir, stdio: "pipe" });
git("init", "-q");
git("add", "-A");
git(
"-c",
"user.email=cast@example.com",
"-c",
"user.name=cast",
"commit",
"-qm",
"manifest",
);
}
const state = join(root, "state");
mkdirSync(join(state, "secrets"), { recursive: true });
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
for (const repo of REPOS) {
// No template refs a secret, but the store still has to exist and open —
// a project whose store is missing is a project cast cannot read, which is
// a fleet ERROR, not a fleet skip (asserted below).
execFileSync("age", ["-r", recipient, "-o", `${repo}.staging.env.age`], {
input: "\n",
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
}
const registry = opts.registry ?? SLUGS;
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: fleet-box",
" team: { id: 0, name: Root Team }",
// A second environment, so "registered, but not HERE" is a state this
// fixture can express — it is the difference between a fleet nobody has
// registered and a fleet registered somewhere else.
" prod:",
" server: prod-box",
" team: { id: 0, name: Root Team }",
"github_apps:",
...SLUGS.map((s) => ` ${s}: hdb-coolify`),
...(registry.length > 0
? [
"projects:",
...registry.flatMap((slug) => [
` ${slug}:`,
` environments: [${opts.registryEnv ?? "staging"}]`,
]),
]
: []),
"",
].join("\n"),
);
return { root, state };
}
function run(
verb: "apply" | "diff",
args: string[],
f?: { root: string },
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", verb, ...args], {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
CAST_AGE_KEY_FILE_STAGING: keyFile,
// The clone is real; only its origin is local. cast builds the URL as
// https://github.com/<org>/<repo>.git and git rewrites it here.
...(f
? {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: `url.${join(f.root, "repos")}/.insteadOf`,
GIT_CONFIG_VALUE_0: "https://github.com/",
}
: {}),
},
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
const fleet = (f: { state: string }) => [
"--env",
"staging",
"--state",
f.state,
"--all",
];
describe("cast diff --all (#26)", () => {
it("iterates the registry, reports each project, and exits 0 on a clean fleet", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(0);
for (const slug of SLUGS) expect(r.output).toContain(slug);
expect(r.output).toContain("[1/3] heavy-duty/alpha");
expect(r.output).toContain("[3/3] heavy-duty/gamma");
// Coverage, said out loud. "clean" alone would be exactly the sentence a
// fleet run over nothing at all prints.
expect(r.output).toContain("registered: 3");
expect(r.output).toContain("read: 3 of 3");
expect(r.output).toContain(
"all 3 registered project(s) were read, and every one is clean.",
);
});
it("exits 1 on drift, naming the drifted project and still reporting the rest", async () => {
const f = fixture((await stubCoolify({ beta: "drift" })).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(1);
expect(r.output).toContain("someones-feature-branch");
expect(r.output).toContain("read: 3 of 3");
expect(r.output).toContain("drift: 1 heavy-duty/beta");
expect(r.output).toContain("clean: 2");
});
// The issue, in one test: a project cast could not reach is an ERROR, not a
// skip — and it does not stop the read either, because stopping would hide
// the drift in the projects never reached.
it("fails the fleet on an unreachable project, and still reads the ones after it", async () => {
const f = fixture(
(await stubCoolify({ beta: "absent", gamma: "drift" })).url,
);
const r = await run("diff", fleet(f), f);
// 2, not 1: an unreadable project is not a diff result, so it outranks the
// drift that WAS found.
expect(r.code).toBe(2);
expect(r.output).toContain("[3/3] heavy-duty/gamma");
expect(r.output).toContain("someones-feature-branch");
expect(r.output).toContain("read: 2 of 3");
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
expect(r.output).toContain("FAILURE, not a diff result");
expect(r.output).not.toContain("every one is clean");
});
it("treats an HTTP error as unreachable, never as an empty project", async () => {
const f = fixture((await stubCoolify({ beta: "error" })).url);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
expect(r.output).toContain("500");
});
it("treats a missing secret store as unreachable, naming the file", async () => {
const f = fixture((await stubCoolify()).url);
execFileSync("rm", [join(f.state, "secrets", "beta.staging.env.age")]);
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
"no secret store for heavy-duty/beta in staging",
);
expect(r.output).toContain("UNREACHABLE: 1 heavy-duty/beta");
});
});
describe("cast diff --all — an empty fleet is not a clean fleet", () => {
it("refuses a state file with no registry at all", async () => {
const f = fixture((await stubCoolify()).url, { registry: [] });
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
'refusing to diff --all: no projects are registered for "staging"',
);
expect(r.output).toContain("no `projects:` block at all");
expect(r.output).toContain("environments: [staging]");
// The thing it must NOT have done — the ONLY reason this refusal exists.
// (The refusal quotes the phrase "0 projects, clean" to name the lie, so
// the assertion is on the verdict line a real run would have printed.)
expect(r.output).not.toContain("read:");
expect(r.output).not.toContain("were read, and every one is clean");
});
// The registry is not empty — it just has nothing to say about THIS
// environment. Same refusal, because it is the same silence: there is nothing
// to iterate, and a run over nothing must never print what a clean run prints.
it("refuses a registry that registers every project somewhere else", async () => {
const f = fixture((await stubCoolify()).url, { registryEnv: "prod" });
const r = await run("diff", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain(
"a registry, but nothing registered for this environment",
);
expect(r.output).toContain("heavy-duty/alpha (prod)");
});
});
describe("cast diff/apply --all — mutually exclusive coordinates", () => {
const cases: Array<[string, string[]]> = [
["<org>/<repo>", ["heavy-duty/alpha"]],
["--path", ["--path", "/tmp/somewhere"]],
["--project", ["--project", "Incubator"]],
["--environment", ["--environment", "production"]],
["--resource", ["--resource", "core=Stack v2"]],
["--hostname-overlay", ["--hostname-overlay", "/tmp/overlay.yaml"]],
];
it.each(cases)("refuses --all with %s", async (flag, args) => {
// No state dir and no Coolify: the refusal lands before cast opens either.
const r = await run("diff", ["--env", "staging", "--all", ...args]);
expect(r.code).toBe(2);
expect(r.output).toContain(
`refusing to diff: --all cannot be combined with ${flag}`,
);
});
it("refuses on apply too, and says so as apply", async () => {
const r = await run("apply", [
"--env",
"staging",
"--all",
"--project",
"Incubator",
]);
expect(r.code).toBe(2);
expect(r.output).toContain(
"refusing to apply: --all cannot be combined with --project",
);
});
});
// The prod ban is older than --all (a feature-branch checkout must not decide
// what prod runs), but --all is what moved it: hoisting loadBindings for the
// registry put it AFTER the point where resolveCheckout used to catch this, so
// the CLI now refuses up front and resolveCheckout still throws behind it. Two
// call sites, one rule — and the up-front one is a code path of its own, so it
// gets a test of its own rather than riding on resolve.test.ts's.
describe("--path with --env prod is refused before anything is opened", () => {
it.each(["diff", "apply"] as const)(
"refuses on %s with no state dir, no store and no Coolify",
async (verb) => {
const r = await run(verb, [
"heavy-duty/alpha",
"--env",
"prod",
"--path",
"/tmp/somewhere",
]);
expect(r.code).toBe(2);
expect(r.output).toContain(
"refuses --path with --env prod: prod always reads the default branch",
);
},
);
});
describe("cast apply --all (#26)", () => {
it("applies every registered project and says what it did", async () => {
const f = fixture((await stubCoolify()).url);
const r = await run("apply", fleet(f), f);
expect(r.code).toBe(0);
expect(r.output).toContain("applied: 3 of 3");
expect(r.output).toContain("all 3 registered project(s) applied.");
expect(r.output).toContain(
"nothing changed — the fleet already matched its manifests.",
);
});
// The one disposition `apply` does not share with `diff`: it STOPS. Half a
// fleet mutated after an unexplained failure is not a fleet cast keeps writing
// to — and the report has to say which half.
it("stops at the first failure, and names what it did and did not touch", async () => {
const stub = await stubCoolify({ beta: "error" });
const f = fixture(stub.url);
const r = await run("apply", fleet(f), f);
expect(r.code).toBe(2);
expect(r.output).toContain("STOPPED at the first failure");
expect(r.output).toContain("applied: 1 of 3 heavy-duty/alpha");
expect(r.output).toContain("FAILED: heavy-duty/beta");
expect(r.output).toContain("not reached: 1 heavy-duty/gamma");
expect(r.output).toContain('"not reached" were NOT touched');
// It did not merely SAY it stopped: gamma was never run…
expect(r.output).not.toContain("[3/3] heavy-duty/gamma");
// …and the box was never asked about it.
expect(stub.hits).not.toContain("/projects/p-gamma/staging");
});
});

214
test/fleet.test.ts Normal file
View file

@ -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({ "<org>/<repo>": "heavy-duty/alpha" })).toBe(
"<org>/<repo>",
);
});
it("passes a legitimate fleet invocation", () => {
// parseArgs hands back `[]` for an unused repeatable flag, not `undefined`.
expect(
fleetConflict({ "--resource": [], "--path": undefined }),
).toBeUndefined();
expect(fleetConflict({})).toBeUndefined();
});
});