fleet operations: cast diff/apply --all over the project registry (#26) #32

Merged
dan-claude-bot merged 2 commits from feat/fleet-all into main 2026-07-13 20:45:55 +00:00
dan-claude-bot commented 2026-07-13 20:23:25 +00:00 (Migrated from github.com)

Closes #26.

Stacked on #30 (#25, the registry — --all iterates it). Merge #30 first; this PR's diff then collapses to the #26 change alone. Until then GitHub shows #25's commit in it too.

Every cast verb was single-project. "Do this to the whole instance" was a shell loop the operator wrote from memory — and the project you forget is the one that drifted.

cast diff  --env prod --all
cast apply --env prod --all

The refactor comes first

The apply/diff block was one long inline body. It is now runProject(ctx, orgRepo) — checkout → secrets → desired → live read → diff → optionally apply — and both the single-repo path and the --all loop call it. There is exactly one implementation of what a project run is; a second, parallel fleet path is how the two drift apart.

openCoolify and the team assert are hoisted out of the per-project body: one --env means one instance and one team, and asserting once still lands strictly before the first project's first read — which is the gate's whole point, since the read is already the lie.

Fail closed, because a skipped project reads exactly like a clean one

diff --all 0 every registered project was read, and every one is clean
diff --all 1 every one was read, at least one has drift
diff --all 2 a project could not be read — outranks drift
apply --all 0 every registered project applied
apply --all ≠0 anything else (stops at the first failure)

Cannot reach collapses clone failure, a manifest with no block for this environment, an absent or undecryptable store, LiveLookup.found === false, and any HTTP error into one fatal outcome — never a skip. fleetExitCode defaults to 2 on any coverage shape it does not recognize.

  • An empty or absent registry refuses (exit 2). "0 projects, clean" is the same lie at fleet scale.
  • --all refuses alongside the positional, --path, --project, --environment, --resource and --hostname-overlay — each names ONE project's checkout, Coolify name or resources; fleet-wide they are meaningless at best (--project X applied to every project in the fleet).
  • diff --all runs every project to completion; stopping early would hide the drift in the ones it never reached. apply --all stops at the first failure and says which projects it applied and which it did not touch — cast does not get to keep mutating a fleet after an unexplained failure.
  • Coverage (read: 2 of 3) prints above the findings, so nothing to report and I looked at nothing cannot render the same.

One fix found en route

The --path + --env prod ban (a feature-branch checkout must not decide what prod runs) was only being caught by accident of resolveCheckout running before the bindings load. Hoisting loadBindings for the registry moved that. The rule is now a value in resolve.ts used by both an up-front CLI refusal and resolveCheckout — one rule, one string, two call sites, and the up-front path has its own test rather than riding on resolve.test.ts's.

apply keeps its existing position that an absent Coolify project is a create, not a failure.

Tests

test/fleet.test.ts (the exit-code table, renderers, refusals) and test/fleet-cli.test.ts (end-to-end over three projects). Since --all forbids --path, the e2e takes the real clone path — git's insteadOf redirects github.com to local repos, so the clone is genuine while the network is not. It covers iteration, per-project reporting, the clean/drift/unreachable aggregates, unreachable-outranks-drift, an unreachable project not stopping the read, missing-store and HTTP-500 as unreachable, both empty-registry variants, all six flag conflicts, and apply --all stopping — asserting from the stub's request log that the un-reached project was never even asked about.

Gates: npm run check, npm run build, npm test (244 passing, was 213) all green.

🤖 Generated with Claude Code

Closes #26. > **Stacked on #30** (#25, the registry — `--all` iterates it). Merge #30 first; this PR's diff then collapses to the #26 change alone. Until then GitHub shows #25's commit in it too. Every cast verb was single-project. "Do this to the whole instance" was a shell loop the operator wrote from memory — and the project you forget is the one that drifted. ```sh cast diff --env prod --all cast apply --env prod --all ``` ### The refactor comes first The `apply`/`diff` block was one long inline body. It is now `runProject(ctx, orgRepo)` — checkout → secrets → desired → live read → diff → optionally apply — and **both** the single-repo path and the `--all` loop call it. There is exactly one implementation of what a project run is; a second, parallel fleet path is how the two drift apart. `openCoolify` and the team assert are hoisted out of the per-project body: one `--env` means one instance and one team, and asserting once still lands strictly *before the first project's first read* — which is the gate's whole point, since the read is already the lie. ### Fail closed, because a skipped project reads exactly like a clean one | | | |---|---| | `diff --all` **0** | every registered project was **read**, and every one is clean | | `diff --all` **1** | every one was read, at least one has drift | | `diff --all` **2** | a project could not be read — **outranks drift** | | `apply --all` **0** | every registered project applied | | `apply --all` **≠0** | anything else (stops at the first failure) | *Cannot reach* collapses clone failure, a manifest with no block for this environment, an absent or undecryptable store, `LiveLookup.found === false`, and any HTTP error into one **fatal** outcome — never a skip. `fleetExitCode` defaults to 2 on any coverage shape it does not recognize. - **An empty or absent registry refuses** (exit 2). "0 projects, clean" is the same lie at fleet scale. - **`--all` refuses alongside** the positional, `--path`, `--project`, `--environment`, `--resource` and `--hostname-overlay` — each names ONE project's checkout, Coolify name or resources; fleet-wide they are meaningless at best (`--project X` applied to every project in the fleet). - `diff --all` runs every project to completion; stopping early would hide the drift in the ones it never reached. `apply --all` **stops at the first failure** and says which projects it applied and which it did not touch — cast does not get to keep mutating a fleet after an unexplained failure. - Coverage (`read: 2 of 3`) prints **above** the findings, so *nothing to report* and *I looked at nothing* cannot render the same. ### One fix found en route The `--path` + `--env prod` ban (a feature-branch checkout must not decide what prod runs) was only being caught *by accident* of `resolveCheckout` running before the bindings load. Hoisting `loadBindings` for the registry moved that. The rule is now a value in `resolve.ts` used by both an up-front CLI refusal and `resolveCheckout` — one rule, one string, two call sites, and the up-front path has its own test rather than riding on `resolve.test.ts`'s. `apply` keeps its existing position that an absent Coolify *project* is a create, not a failure. ### Tests `test/fleet.test.ts` (the exit-code table, renderers, refusals) and `test/fleet-cli.test.ts` (end-to-end over three projects). Since `--all` forbids `--path`, the e2e takes the **real clone path** — git's `insteadOf` redirects `github.com` to local repos, so the clone is genuine while the network is not. It covers iteration, per-project reporting, the clean/drift/unreachable aggregates, unreachable-outranks-drift, an unreachable project *not* stopping the read, missing-store and HTTP-500 as unreachable, both empty-registry variants, all six flag conflicts, and `apply --all` stopping — asserting from the stub's request log that the un-reached project was never even asked about. Gates: `npm run check`, `npm run build`, `npm test` (244 passing, was 213) all green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#32
No description provided.