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:
parent
18660041f9
commit
8dde6dbc05
8 changed files with 1447 additions and 106 deletions
73
README.md
73
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
397
src/cli.ts
397
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 <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,68 +838,85 @@ 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,
|
||||
}),
|
||||
);
|
||||
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({
|
||||
|
|
|
|||
320
src/fleet.ts
Normal file
320
src/fleet.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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
427
test/fleet-cli.test.ts
Normal 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
214
test/fleet.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue