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
|
|
|
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";
|
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
|
|
|
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";
|
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 manifest-first bootstrap (#104), end to end: fresh box,
|
|
|
|
|
// registered project, a manifest that declares databases only and refs no
|
|
|
|
|
// secret — the exact shape the 2026-07-19 release drill hit. There used to be
|
|
|
|
|
// no path to the first apply: `apply` refused on the absent store, `capture`
|
|
|
|
|
// rightly refused the absent project, and the drill unblocked with a hand-made
|
|
|
|
|
// empty store nothing documented.
|
|
|
|
|
//
|
|
|
|
|
// The rule under test: the "no secret store" refusal is gated on the manifest
|
|
|
|
|
// actually REFERENCING a secret. Zero ${…} refs → an absent store is treated
|
|
|
|
|
// as empty, a loud note names the path, and no age key is demanded (nothing to
|
|
|
|
|
// decrypt, nothing to protect yet). One ${…} ref → the refusal, byte-identical
|
|
|
|
|
// to what it always said. A present store keeps decrypting exactly as before.
|
|
|
|
|
|
|
|
|
|
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-");
|
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
|
|
|
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[] = [];
|
|
|
|
|
|
|
|
|
|
// A fresh box: the project is registered on Coolify but its environment holds
|
|
|
|
|
// nothing — the state one API call after `cast project create`, and the state
|
|
|
|
|
// the drill's first apply ran against.
|
|
|
|
|
async function stubCoolify(): 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" });
|
|
|
|
|
if (path === "/projects") return json([{ uuid: "p1", name: "fresh" }]);
|
|
|
|
|
if (path === "/projects/p1/staging") return json({ applications: [] });
|
|
|
|
|
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()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The drill's manifest, minimized: databases only, zero ${…} refs anywhere.
|
|
|
|
|
const ZERO_REFS_MANIFEST = `project: fresh
|
|
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
applications: {}
|
|
|
|
|
databases:
|
|
|
|
|
fresh-db:
|
|
|
|
|
type: postgresql
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// The same environment the moment one template gains a placeholder — the
|
|
|
|
|
// boundary at which the old refusal must return, word for word.
|
|
|
|
|
const ONE_REF_MANIFEST = `project: fresh
|
|
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: heavy-duty/fresh, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: / }
|
|
|
|
|
domains: ["http://core.example.com"]
|
|
|
|
|
env_template: core.env
|
|
|
|
|
databases:
|
|
|
|
|
fresh-db:
|
|
|
|
|
type: postgresql
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
function fixture(
|
|
|
|
|
url: string,
|
|
|
|
|
opts: { manifest: string; store?: boolean } = {
|
|
|
|
|
manifest: ZERO_REFS_MANIFEST,
|
|
|
|
|
},
|
|
|
|
|
) {
|
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-");
|
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
|
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
|
|
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), opts.manifest);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(checkout, ".infra", "env", "core.env"),
|
|
|
|
|
"API_KEY=${API_KEY}\n",
|
|
|
|
|
);
|
|
|
|
|
|
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-");
|
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
|
|
|
mkdirSync(join(state, "secrets"));
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, ".coolify.env"),
|
|
|
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
|
|
|
|
);
|
|
|
|
|
if (opts.store) {
|
|
|
|
|
execFileSync("age", ["-r", recipient, "-o", "fresh.staging.env.age"], {
|
|
|
|
|
input: "\n",
|
|
|
|
|
cwd: join(state, "secrets"),
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" staging:",
|
|
|
|
|
" server: fresh-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
"github_apps:",
|
|
|
|
|
" fresh: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
return { checkout, state };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `withKey: false` is the greenfield claim itself: the run is spawned with no
|
|
|
|
|
// CAST_AGE_KEY_FILE_STAGING and a HOME that holds no standing key, so if cast
|
|
|
|
|
// so much as ASKS for the age key, the run dies "no age key for staging" and
|
|
|
|
|
// the assertion on the output catches it.
|
|
|
|
|
function run(
|
|
|
|
|
args: string[],
|
|
|
|
|
opts: { withKey: boolean },
|
|
|
|
|
): Promise<{ code: number; output: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const { CAST_AGE_KEY_FILE_STAGING: _dropped, ...inherited } = process.env;
|
|
|
|
|
const env = opts.withKey
|
|
|
|
|
? { ...inherited, CAST_AGE_KEY_FILE_STAGING: keyFile }
|
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
|
|
|
: { ...inherited, HOME: tmp("cast-home-") };
|
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 child = spawn("node", ["dist/cli.js", "diff", ...args], {
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
env,
|
|
|
|
|
});
|
|
|
|
|
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 base = (f: { checkout: string; state: string }) => [
|
|
|
|
|
"heavy-duty/fresh",
|
|
|
|
|
"--env",
|
|
|
|
|
"staging",
|
|
|
|
|
"--path",
|
|
|
|
|
f.checkout,
|
|
|
|
|
"--state",
|
|
|
|
|
f.state,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("greenfield: zero ${…} refs and no store (#104)", () => {
|
|
|
|
|
it("proceeds to a full plan, says the store was absent and unneeded, and never asks for an age key", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url, {
|
|
|
|
|
manifest: ZERO_REFS_MANIFEST,
|
|
|
|
|
});
|
|
|
|
|
const r = await run(base(f), { withKey: false });
|
|
|
|
|
// The plan, not a refusal: the database is there to create, so this is an
|
|
|
|
|
// ordinary drift exit — which is the whole point, the first apply's plan.
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("fresh-db");
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
"NOTE: no secret store for heavy-duty/fresh in staging",
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
join(f.state, "secrets", "fresh.staging.env.age"),
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain("proceeds without a store or an age key");
|
|
|
|
|
// The two ways the old behavior would have surfaced, both absent: the
|
|
|
|
|
// refusal (whose body, unlike the note, tells you what the store is FOR),
|
|
|
|
|
// and — with no key in the spawn env at all — the key demand.
|
|
|
|
|
expect(r.output).not.toContain("resolved from that store");
|
|
|
|
|
expect(r.output).not.toContain("no age key for staging");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the original refusal, word for word, the moment a template holds a ${…} ref", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url, {
|
|
|
|
|
manifest: ONE_REF_MANIFEST,
|
|
|
|
|
});
|
|
|
|
|
const r = await run(base(f), { withKey: false });
|
|
|
|
|
// Exit 1 is what a single-project refusal has always exited with: the
|
|
|
|
|
// throw lands in main()'s rejection handler, same as before #104. The
|
|
|
|
|
// fleet flavor of this refusal (exit 2, UNREACHABLE) is fleet-cli.test.ts.
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
"no secret store for heavy-duty/fresh in staging",
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
`looked for: ${join(f.state, "secrets", "fresh.staging.env.age")}`,
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain(
|
|
|
|
|
"The manifest's ${…} refs are resolved from that store",
|
|
|
|
|
);
|
|
|
|
|
expect(r.output).toContain("`cast capture` writes one from a live box.");
|
|
|
|
|
// The refusal, not the plan and not the note.
|
|
|
|
|
expect(r.output).not.toContain("NOTE:");
|
|
|
|
|
expect(r.output).not.toContain("fresh-db");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still opens a store that DOES exist — zero refs or not, a written store is decrypted as before", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url, {
|
|
|
|
|
manifest: ZERO_REFS_MANIFEST,
|
|
|
|
|
store: true,
|
|
|
|
|
});
|
|
|
|
|
// The pre-#104 shape: store on disk, key injected. Same plan as the
|
|
|
|
|
// greenfield run, and no note — the store was there, so nothing to say.
|
|
|
|
|
const r = await run(base(f), { withKey: true });
|
|
|
|
|
expect(r.code).toBe(1);
|
|
|
|
|
expect(r.output).toContain("fresh-db");
|
|
|
|
|
expect(r.output).not.toContain("NOTE: no secret store");
|
|
|
|
|
});
|
|
|
|
|
});
|