feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +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 { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +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: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
|
|
|
|
|
// The read side, end to end, against a stub Coolify shaped like a box nobody
|
|
|
|
|
// declared: its project is `Incubator` (capital I), its environment is
|
|
|
|
|
// `production` (Coolify's default, not ours), and its application is called
|
|
|
|
|
// `incubator-stack` rather than the manifest's `core`.
|
|
|
|
|
//
|
|
|
|
|
// Every one of those three is a real name from the live box this work came out
|
|
|
|
|
// of, and each one used to fail differently and badly:
|
|
|
|
|
//
|
|
|
|
|
// project → refused (already fixed, #12)
|
|
|
|
|
// environment → refused, and the "obvious" fix was to rename OUR environment
|
|
|
|
|
// to match the box — letting a machine due for deletion name
|
|
|
|
|
// the new box's environment forever (#17)
|
|
|
|
|
// resource → NOT refused: reported every required secret as individually
|
|
|
|
|
// MISSING from a box that was serving production at the time,
|
|
|
|
|
// and invited --override to hand-carry all of them (#18)
|
|
|
|
|
|
|
|
|
|
const LIVE_ENV = {
|
|
|
|
|
MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET",
|
|
|
|
|
ADMIN_EMAIL: "founder@real-company.com",
|
|
|
|
|
LEFTOVER_FROM_2019: "nobody-asked-for-this",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let recipient: 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: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
const keyFile = join(dir, "age.key");
|
|
|
|
|
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
|
|
|
|
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
|
|
|
|
encoding: "utf8",
|
|
|
|
|
}).trim();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
|
|
|
const stubs: Stub[] = [];
|
|
|
|
|
|
|
|
|
|
// appName is the knob: `incubator-stack` is the hand-built box (the manifest's
|
|
|
|
|
// `core` does not exist on it); `core` is the box whose names happen to line up.
|
|
|
|
|
async function stubCoolify(appName: string): Promise<Stub> {
|
|
|
|
|
const server = createServer((req, res) => {
|
|
|
|
|
const path = (req.url ?? "").replace("/api/v1", "");
|
|
|
|
|
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" });
|
|
|
|
|
// Named as someone typed it, not as the repo slug would derive it.
|
|
|
|
|
if (path === "/projects") return json([{ uuid: "p1", name: "Incubator" }]);
|
|
|
|
|
// Coolify's default environment name — ours is `staging`.
|
|
|
|
|
if (path === "/projects/p1/production")
|
|
|
|
|
return json({ applications: [{ name: appName, uuid: "a1" }] });
|
|
|
|
|
if (path === "/applications/a1/envs")
|
|
|
|
|
return json(
|
|
|
|
|
Object.entries(LIVE_ENV).map(([key, real_value]) => ({
|
|
|
|
|
key,
|
|
|
|
|
real_value,
|
|
|
|
|
value: "REDACTED",
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
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}`,
|
|
|
|
|
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 = `project: incubator
|
|
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: heavy-duty/incubator, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: / }
|
|
|
|
|
domains: ["http://core.example.com"]
|
|
|
|
|
env_template: core.staging.env.template
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const TEMPLATE = `NODE_ENV=production
|
|
|
|
|
MAILGUN_API_KEY=\${MAILGUN_API_KEY}
|
|
|
|
|
ADMIN_EMAIL=\${ADMIN_EMAIL}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
function fixture(url: string) {
|
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 checkout = tmp("cast-co-");
|
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
|
|
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(checkout, ".infra", "env", "core.staging.env.template"),
|
|
|
|
|
TEMPLATE,
|
|
|
|
|
);
|
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 state = tmp("cast-state-");
|
feat: read-side coordinates (#17, #18) + cast inventory (#19)
Three fixes at one seam: cast could not READ a box it did not build.
#17 — the environment had no read-side coordinate.
`--project` exists because a hand-built project is called whatever someone
typed. The environment has the identical problem and had no flag, so reading
a legacy box forced a choice between mutating that box's UI and renaming OUR
environment to match it. The second is what happened: `prod` became
`production` across the manifest and environments.yaml — a box being deleted
next week naming the environment of the box that replaces it, permanently
(apply creates the environment from --env), moving the store to
incubator.production.env.age and invalidating every runbook. Reverted.
`--environment` is now the coordinate. `--env` stays OURS: manifest block,
binding, age key, store path, team assert. `--environment` is theirs, on the
wire, and nothing else.
#18 — an absent RESOURCE reported as N missing secrets.
The D-237 lie, one level deeper. A resource that is absent reads back exactly
like one present with no env vars, so capture reported all 15 required names
as individually MISSING — from a box that was serving production and sending
mail at that moment — and offered --override as the remedy. Taking that offer
would have "worked": a valid store, hand-carried values, and the real finding
(the manifest and the box disagree about what the app is called) buried.
capture now refuses on the resource, names what does exist, and only reports
per-name MISSING for resources it actually found — where it means what it says.
#19 — cast inventory: see the box before you adopt it.
The missing first step. cast could describe a box it built, change one, and
take values off one for names a manifest declares — but not tell you what is
on a box you did not build, which is the first thing adoption needs. Every
mismatch above surfaced as a refusal from a verb already committed to a course
of action, and the tempting fix for two of them was to bend the manifest toward
the legacy box.
inventory reads resources and env var KEYS (never values), sorts them into
on-both / manifest-only / box-only, and needs no store, no age key and no
recipient — it runs before adoption exists. Its output is a document:
inventory → human reads → manifest PR → capture → apply
That boundary is what lets capture stay strict. inventory may read everything,
because a person reads its output. capture may only write what the manifest
declares, because `apply` reads its output. Same box, two consumers, two
contracts. A manifest-draft emitter is deliberately NOT included: it would be
one `cp` away from becoming desired state, which is the failure this design
exists to prevent.
Zero drift against a hand-built box is reported as suspicious, not as a pass.
npm run check + build clean; 164 tests passing, 18 files (was 151/16).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:20:53 +00:00
|
|
|
mkdirSync(join(state, "secrets"));
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, ".coolify.env"),
|
|
|
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
|
|
|
|
);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" staging:",
|
|
|
|
|
" server: staging-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
` age_recipient: ${recipient}`,
|
|
|
|
|
"github_apps:",
|
|
|
|
|
" incubator: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
checkout,
|
|
|
|
|
state,
|
|
|
|
|
store: join(state, "secrets", "incubator.staging.env.age"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function run(
|
|
|
|
|
verb: string,
|
|
|
|
|
args: string[],
|
|
|
|
|
opts: { stdin?: string; env?: Record<string, string> } = {},
|
|
|
|
|
): Promise<{ code: number; output: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const child = spawn("node", ["dist/cli.js", verb, ...args], {
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
env: { ...process.env, ...opts.env },
|
|
|
|
|
});
|
|
|
|
|
let output = "";
|
|
|
|
|
child.stdout.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stderr.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stdin.end(opts.stdin ?? "");
|
|
|
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const base = (f: ReturnType<typeof fixture>) => [
|
|
|
|
|
"heavy-duty/incubator",
|
|
|
|
|
"--env",
|
|
|
|
|
"staging",
|
|
|
|
|
"--state",
|
|
|
|
|
f.state,
|
|
|
|
|
"--path",
|
|
|
|
|
f.checkout,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("--environment (the read-side coordinate, #17)", () => {
|
|
|
|
|
it("reads the box's environment name while the store keeps OURS", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("core")).url);
|
|
|
|
|
const r = await run(
|
|
|
|
|
"capture",
|
|
|
|
|
[
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
"--override",
|
|
|
|
|
"ADMIN_EMAIL",
|
|
|
|
|
],
|
|
|
|
|
{
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// THE POINT: the box calls it `production`; we call it `staging`. The store
|
|
|
|
|
// is keyed by OUR name. A hand-built box does not get to name our
|
|
|
|
|
// environments, our store, or our age key — it only gets to be read.
|
|
|
|
|
expect(existsSync(f.store)).toBe(true);
|
|
|
|
|
expect(
|
|
|
|
|
existsSync(join(f.state, "secrets", "incubator.production.env.age")),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses an absent environment by naming --environment, not by inviting a rename", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("core")).url);
|
|
|
|
|
// No --environment: cast looks for `staging`, the box has `production`.
|
|
|
|
|
const r = await run("capture", [...base(f), "--project", "Incubator"]);
|
|
|
|
|
expect(r.code).toBe(2);
|
|
|
|
|
expect(r.output).toContain('has no environment "staging"');
|
|
|
|
|
expect(r.output).toContain("Pass --environment <name>");
|
|
|
|
|
// The old message said "re-run with --env naming the environment as it
|
|
|
|
|
// exists here" — i.e. adopt the box's vocabulary as our own. That is the
|
|
|
|
|
// sentence that cost us a rename across three repos.
|
|
|
|
|
expect(r.output).not.toContain("Re-run with --env");
|
|
|
|
|
expect(existsSync(f.store)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("absent resource (#18)", () => {
|
|
|
|
|
it("refuses on the RESOURCE, naming what exists, instead of reporting every secret missing", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("incubator-stack")).url);
|
|
|
|
|
const r = await run("capture", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(2);
|
|
|
|
|
// The finding: the manifest and the box disagree about what this is called.
|
|
|
|
|
expect(r.output).toContain("core");
|
|
|
|
|
expect(r.output).toContain("incubator-stack");
|
|
|
|
|
expect(r.output).toContain("do not exist here");
|
|
|
|
|
// NOT the old report — a wall of per-name MISSING against a box that HAS
|
|
|
|
|
// every one of those secrets, sitting right there under another name, with
|
|
|
|
|
// --override offered as the remedy (which would have written a perfectly
|
|
|
|
|
// valid store and buried the real problem forever).
|
|
|
|
|
//
|
|
|
|
|
// The assertion is that no INDIVIDUAL SECRET is named at all: this is a
|
|
|
|
|
// resource-level finding, and reporting it per-secret is what made the
|
|
|
|
|
// original failure so convincingly wrong. (Asserting on the word "MISSING"
|
|
|
|
|
// would only catch the prose that explains why we are not doing that.)
|
|
|
|
|
for (const name of Object.keys(LIVE_ENV)) {
|
|
|
|
|
expect(r.output).not.toContain(name);
|
|
|
|
|
}
|
|
|
|
|
expect(existsSync(f.store)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("cast inventory (#19)", () => {
|
|
|
|
|
it("shows both sides of a hand-built box — and no values", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("incubator-stack")).url);
|
|
|
|
|
const r = await run("inventory", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// Declared, absent from the box.
|
|
|
|
|
expect(r.output).toContain("core");
|
|
|
|
|
// On the box, undeclared — including a var the manifest never heard of.
|
|
|
|
|
expect(r.output).toContain("incubator-stack");
|
|
|
|
|
expect(r.output).toContain("LEFTOVER_FROM_2019");
|
|
|
|
|
// Keys, never values. This artifact is meant to be pasted into a PR.
|
|
|
|
|
for (const value of Object.values(LIVE_ENV)) {
|
|
|
|
|
expect(r.output).not.toContain(value);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("needs no secret store, no age key, and no age_recipient to run", async () => {
|
|
|
|
|
// The whole point: inventory runs BEFORE adoption, when none of those exist.
|
|
|
|
|
const f = fixture((await stubCoolify("incubator-stack")).url);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(f.state, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" staging:",
|
|
|
|
|
" server: staging-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
"github_apps:",
|
|
|
|
|
" incubator: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
const r = await run("inventory", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(r.output).toContain("inventory — heavy-duty/incubator staging");
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: --resource, the third name a hand-built box does not share with you
#20 shipped the refusal without shipping the resolution: capture correctly
refuses when a manifest resource does not exist on the source, and then
there was no way to say "it's over there, under another name."
Found immediately, on the box that motivated it. The manifest says `core`,
`landing`, `postgres`, `redis`, `umami`. The box says `Incubator Stack v2`,
`Incubator Landing`, `Incubator Database v2`, `Incubator Redis v2`,
`Incubator Umami`. Neither is wrong — one names things for a human reading a
UI, the other for a machine reading a diff — and neither gets to overwrite the
other.
--resource core="Incubator Stack v2" (repeatable)
Applied at the boundary: live resources are renamed to the manifest's
vocabulary once, immediately after the lookup, so computeDiff / classify /
reconcile all match by name exactly as before and none of them needs to know a
hand-built box was involved.
Read-side only, and `apply` refuses it up front — before a clone, a decrypt or
a single call. apply CREATES under the manifest's names, so an alias there
could only mean "adopt the existing one instead": a different operation nobody
has asked for, whose silent failure mode is a duplicate resource created beside
the one you were pointing at.
diff needed this as much as capture did. Without it, a --full diff against a
box whose resources are named differently reports every manifest resource as
"to create" and never mentions the live ones — the D-237 lie by another route,
a confident full-create plan against a box that has all of it under other names.
That diff is the staleness gate of a live migration.
Two smaller things, both about not laundering a naming gap into a pass:
- inventory, when NOTHING matched and yet the box has resources, now says so
and prints the --resource lines to paste. "The box is empty" is exactly the
wrong conclusion, and it was the easy one to draw.
- the absent-resource refusal prints the same, per absent resource.
An alias whose left side names no manifest resource is an error, not a no-op:
a typo would otherwise map nothing, leave the real resource looked-up under its
own name, and refuse with no hint that the flag had missed.
inventory keeps the box's own name beside ours in the report (`core ←
"Incubator Stack v2" on the box`) — a document that renamed the box's resources
to our vocabulary and never mentioned theirs would be unusable against the UI
it describes.
npm run check + build clean; 169 tests passing (was 164).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:50:06 +00:00
|
|
|
|
|
|
|
|
describe("--resource (the third name, #23)", () => {
|
|
|
|
|
it("captures from a resource the box calls something else", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("Incubator Stack v2")).url);
|
|
|
|
|
const r = await run(
|
|
|
|
|
"capture",
|
|
|
|
|
[
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
"--resource",
|
|
|
|
|
"core=Incubator Stack v2",
|
|
|
|
|
"--override",
|
|
|
|
|
"ADMIN_EMAIL",
|
|
|
|
|
],
|
|
|
|
|
{
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(existsSync(f.store)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("shows the box's own name beside ours, and diffs the KEYS of the pair", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("Incubator Stack v2")).url);
|
|
|
|
|
const r = await run("inventory", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
"--resource",
|
|
|
|
|
"core=Incubator Stack v2",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// Matched — and the box's name is still there. A document that renamed the
|
|
|
|
|
// box's resources to our vocabulary and never mentioned theirs would be
|
|
|
|
|
// useless against the UI it describes.
|
|
|
|
|
expect(r.output).toContain('← "Incubator Stack v2" on the box');
|
|
|
|
|
// The finding that only becomes visible once they are PAIRED: a var the box
|
|
|
|
|
// carries that the manifest has never heard of.
|
|
|
|
|
expect(r.output).toContain("box only:");
|
|
|
|
|
expect(r.output).toContain("LEFTOVER_FROM_2019");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("tells you what to map, when nothing matched but the box is full", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("Incubator Stack v2")).url);
|
|
|
|
|
const r = await run("inventory", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
// Not "the box is empty" — which is how a full-create plan gets laundered
|
|
|
|
|
// into a pass. It is a naming gap, and the fix is printed.
|
|
|
|
|
expect(r.output).toContain("NOTHING matched");
|
|
|
|
|
expect(r.output).toContain('--resource core="<what this box calls it>"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses an alias for a resource the manifest never declared", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("Incubator Stack v2")).url);
|
|
|
|
|
const r = await run("inventory", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--project",
|
|
|
|
|
"Incubator",
|
|
|
|
|
"--environment",
|
|
|
|
|
"production",
|
|
|
|
|
"--resource",
|
|
|
|
|
"cores=Incubator Stack v2",
|
|
|
|
|
]);
|
|
|
|
|
// A typo here would be silent and expensive: the alias maps nothing, the
|
|
|
|
|
// real resource is looked up under its own name, and the run refuses with
|
|
|
|
|
// no hint that the flag missed.
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toContain('declares no resource named "cores"');
|
|
|
|
|
expect(r.output).toContain("core");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses --resource on apply — it is a read-side coordinate", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify("Incubator Stack v2")).url);
|
|
|
|
|
const r = await run("apply", [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--resource",
|
|
|
|
|
"core=Incubator Stack v2",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.code).toBe(2);
|
|
|
|
|
expect(r.output).toContain("read-side coordinate");
|
|
|
|
|
});
|
|
|
|
|
});
|