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>
2026-07-13 20:21:27 +00:00
|
|
|
import { execFileSync, spawn } from "node:child_process";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
import { createServer } from "node:http";
|
|
|
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
|
|
|
|
|
// `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(() => {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const dir = tmp("cast-age-");
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
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()));
|
|
|
|
|
});
|
|
|
|
|
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
const manifest = (repo: string, withRef = false) => `project: ${repo}
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: heavy-duty/${repo}, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: / }
|
|
|
|
|
domains: ["http://${repo}.example.com"]
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
${withRef ? " env_template: core.env\n" : ""}`;
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
|
|
|
|
|
// 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).
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
// `refIn` gives ONE repo a template with a ${…} ref: since #104 gated the
|
|
|
|
|
// missing-store refusal on the manifest actually referencing a secret, a
|
|
|
|
|
// fixture that wants to exercise that refusal has to reference one.
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
function fixture(
|
|
|
|
|
url: string,
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
opts: { registry?: string[]; registryEnv?: string; refIn?: string } = {},
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
) {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const root = tmp("cast-fleet-");
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
for (const repo of REPOS) {
|
|
|
|
|
const dir = join(root, "repos", "heavy-duty", `${repo}.git`);
|
|
|
|
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
writeFileSync(
|
|
|
|
|
join(dir, ".infra", "manifest.yaml"),
|
|
|
|
|
manifest(repo, repo === opts.refIn),
|
|
|
|
|
);
|
|
|
|
|
if (repo === opts.refIn) {
|
|
|
|
|
mkdirSync(join(dir, ".infra", "env"));
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(dir, ".infra", "env", "core.env"),
|
|
|
|
|
"API_KEY=${API_KEY}\n",
|
|
|
|
|
);
|
|
|
|
|
}
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
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) {
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
// No template refs a secret, so since #104 none of these stores is strictly
|
|
|
|
|
// required — but the pre-greenfield shape (store on disk, key injected) is
|
|
|
|
|
// the shape most fleets are in, and it has to keep working unchanged. The
|
|
|
|
|
// missing-store-with-a-ref refusal is exercised below by deleting one.
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
// `refIn` matters: since #104 the refusal is gated on the manifest actually
|
|
|
|
|
// referencing a secret, so the missing store is only an error because beta's
|
|
|
|
|
// template holds a ${…} ref. The gate's other half is the test after this one.
|
|
|
|
|
it("treats a missing secret store as unreachable when a template refs a secret, naming the file", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url, { refIn: "beta" });
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
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");
|
|
|
|
|
});
|
fix: a manifest with no ${…} refs applies without a store (#104)
The greenfield manifest-first bootstrap was a chicken-and-egg with no
exit, found by the 2026-07-19 release drill: fresh Coolify instance,
registered project, a manifest declaring databases only and resolving
zero ${…} refs. apply refused with "no secret store", and capture — the
documented way to get a store — rightly refused a project absent on the
box, because apply is the verb that would create it. The drill unblocked
with a hand-rolled empty age store, documented nowhere.
Now diff/apply gate the refusal on the manifest actually referencing a
secret, asked via requiredSecrets — the same parser resolution uses, so
the two cannot disagree. Zero refs: an absent store is treated as empty,
a loud one-line note names the path it would live at, and the age key is
not demanded (nothing to decrypt, nothing to protect yet). One ref: the
refusal returns byte-identical to before. capture and destroy are
untouched.
Fixes #104
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:15 +00:00
|
|
|
|
|
|
|
|
// The greenfield gate (#104) under --all: a zero-refs project whose store was
|
|
|
|
|
// never written is READ, not failed — the note prints, the fleet stays whole.
|
|
|
|
|
it("reads a zero-refs project with no store, noting the absence instead of failing it", 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(0);
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
"NOTE: no secret store for heavy-duty/beta in staging",
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain("read: 3 of 3");
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
"all 3 registered project(s) were read, and every one is clean.",
|
|
|
|
|
);
|
|
|
|
|
});
|
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>
2026-07-13 20:21:27 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
});
|