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: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
import {
|
|
|
|
|
assertWritable,
|
|
|
|
|
formatInstance,
|
|
|
|
|
knownInstances,
|
|
|
|
|
loadInstance,
|
|
|
|
|
} from "../src/config.js";
|
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: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
|
|
|
|
|
|
|
|
// A state dir with a default .coolify.env and any number of named instances.
|
|
|
|
|
function stateDir(
|
|
|
|
|
named: Record<string, string> = {},
|
|
|
|
|
defaultEnv?: string,
|
|
|
|
|
): 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 dir = tmp("cast-state-");
|
feat: select the Coolify instance by name instead of editing .coolify.env (#14)
loadConfig read exactly one COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN from
<state>/.coolify.env, with no flag or env override: the connection target was
implicit in a file's current contents. Retargeting cast meant hand-editing a
live credential file — and putting it back afterwards. The failure mode of
getting that wrong is running `apply` against production.
That is not hypothetical during the prod migration (incubator D-193): the
state repo's .coolify.env holds a write+deploy token for the NEW control
plane, while the verification gate needs a --full diff against the legacy,
hand-built box still serving live users.
- Named instances: <state>/.coolify/<name>.env, each with its own base URL
and token. --instance <name> on every verb that reaches Coolify.
- environments.yaml may bind one per environment (`instance: prod-cp`), so
--env selects the right control plane with no flag and no file edit at all.
An explicit --instance still wins, so a one-off read against a legacy box
needs no change to that file either.
- Refuse, don't guess, on an unknown --instance — naming the instances that
do exist, in the same spirit as the absent-target refusal (#12/D-237).
Falling back to the default here is exactly how a diff meant for a legacy
box gets run against production.
- An instance may declare COOLIFY_READ_ONLY=true; apply, smoke and server add
then refuse it before their first call, even though the token itself would
permit the writes. "I pointed the wrong token at the wrong box" becomes an
exit code rather than a live incident.
- Every command that reaches a Coolify now SAYS which one, next to the team
assert. It is the most consequential input and the least visible one.
With no --instance and no binding, behavior is byte-for-byte what it was.
The CLI tests spawn cast against stub Coolifys that record what they were
asked, so "which instance did it actually talk to" is answered from the wire
rather than from cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:45 +00:00
|
|
|
if (defaultEnv !== undefined) {
|
|
|
|
|
writeFileSync(join(dir, ".coolify.env"), defaultEnv);
|
|
|
|
|
}
|
|
|
|
|
if (Object.keys(named).length > 0) {
|
|
|
|
|
mkdirSync(join(dir, ".coolify"));
|
|
|
|
|
for (const [name, body] of Object.entries(named)) {
|
|
|
|
|
writeFileSync(join(dir, ".coolify", `${name}.env`), body);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OK =
|
|
|
|
|
'COOLIFY_BASE_URL="https://cp.example.com"\nCOOLIFY_ACCESS_TOKEN="t"\n';
|
|
|
|
|
|
|
|
|
|
describe("loadInstance", () => {
|
|
|
|
|
// The whole point of #14 is that adding this must change nothing for anyone
|
|
|
|
|
// who does not use it.
|
|
|
|
|
it("reads .coolify.env when no instance is named — unchanged behavior", () => {
|
|
|
|
|
const dir = stateDir({}, OK);
|
|
|
|
|
const inst = loadInstance(dir);
|
|
|
|
|
expect(inst).toMatchObject({
|
|
|
|
|
name: "default",
|
|
|
|
|
baseUrl: "https://cp.example.com",
|
|
|
|
|
token: "t",
|
|
|
|
|
readOnly: false,
|
|
|
|
|
file: join(dir, ".coolify.env"),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reads .coolify/<name>.env for a named instance", () => {
|
|
|
|
|
const dir = stateDir(
|
|
|
|
|
{
|
|
|
|
|
legacy:
|
|
|
|
|
'COOLIFY_BASE_URL="https://old.example.com"\nCOOLIFY_ACCESS_TOKEN="lt"\n',
|
|
|
|
|
},
|
|
|
|
|
OK,
|
|
|
|
|
);
|
|
|
|
|
expect(loadInstance(dir, "legacy")).toMatchObject({
|
|
|
|
|
name: "legacy",
|
|
|
|
|
baseUrl: "https://old.example.com",
|
|
|
|
|
token: "lt",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Refuse, don't guess (#12's position on an absent target, applied to the
|
|
|
|
|
// connection target). Falling back to the default instance here is how a
|
|
|
|
|
// diff meant for a legacy box gets run against production.
|
|
|
|
|
it("refuses an unknown instance and names the ones that exist", () => {
|
|
|
|
|
const dir = stateDir({ "prod-cp": OK, "staging-cp": OK }, OK);
|
|
|
|
|
expect(() => loadInstance(dir, "legacy")).toThrow(
|
|
|
|
|
/no Coolify instance named "legacy"/,
|
|
|
|
|
);
|
|
|
|
|
expect(() => loadInstance(dir, "legacy")).toThrow(/prod-cp, staging-cp/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("says so plainly when no named instances exist at all", () => {
|
|
|
|
|
const dir = stateDir({}, OK);
|
|
|
|
|
expect(() => loadInstance(dir, "legacy")).toThrow(/\(none\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("never silently falls back to the default instance", () => {
|
|
|
|
|
const dir = stateDir({}, OK);
|
|
|
|
|
expect(() => loadInstance(dir, "legacy")).toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reads COOLIFY_READ_ONLY off an instance", () => {
|
|
|
|
|
const dir = stateDir({ legacy: `${OK}COOLIFY_READ_ONLY=true\n` });
|
|
|
|
|
expect(loadInstance(dir, "legacy").readOnly).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still requires base url and token", () => {
|
|
|
|
|
const dir = stateDir({ broken: 'COOLIFY_BASE_URL="https://x"\n' });
|
|
|
|
|
expect(() => loadInstance(dir, "broken")).toThrow(
|
|
|
|
|
/COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("knownInstances", () => {
|
|
|
|
|
it("lists named instances, sorted, and is empty when there are none", () => {
|
|
|
|
|
expect(knownInstances(stateDir({ b: OK, a: OK }, OK))).toEqual(["a", "b"]);
|
|
|
|
|
expect(knownInstances(stateDir({}, OK))).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("assertWritable", () => {
|
|
|
|
|
const inst = (readOnly: boolean) => ({
|
|
|
|
|
name: "legacy",
|
|
|
|
|
baseUrl: "https://old.example.com",
|
|
|
|
|
token: "t",
|
|
|
|
|
readOnly,
|
|
|
|
|
file: "/s/.coolify/legacy.env",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// "I pointed the wrong token at the wrong box" becomes an exit code rather
|
|
|
|
|
// than a live incident — and it holds even when the TOKEN would permit the
|
|
|
|
|
// write. That is the point: the declaration is the guard, not the scope.
|
|
|
|
|
it("refuses a write against a read-only instance, naming the declaration", () => {
|
|
|
|
|
expect(() => assertWritable(inst(true), "apply")).toThrow(
|
|
|
|
|
/refusing to apply.*read-only/s,
|
|
|
|
|
);
|
|
|
|
|
expect(() => assertWritable(inst(true), "apply")).toThrow(
|
|
|
|
|
/COOLIFY_READ_ONLY=true in \/s\/\.coolify\/legacy\.env/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("allows writes against a normal instance", () => {
|
|
|
|
|
expect(() => assertWritable(inst(false), "apply")).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("formatInstance", () => {
|
|
|
|
|
it("names the instance and its base url, and flags read-only", () => {
|
|
|
|
|
const base = {
|
|
|
|
|
name: "legacy",
|
|
|
|
|
baseUrl: "https://old.example.com",
|
|
|
|
|
token: "t",
|
|
|
|
|
file: "f",
|
|
|
|
|
};
|
|
|
|
|
expect(formatInstance({ ...base, readOnly: false })).toBe(
|
|
|
|
|
"instance legacy → https://old.example.com",
|
|
|
|
|
);
|
|
|
|
|
expect(formatInstance({ ...base, readOnly: true })).toMatch(/read-only/);
|
|
|
|
|
});
|
|
|
|
|
});
|