cast/test/greenfield-cli.test.ts
dan-claude-bot c42699d6b5 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 13:06:34 +00:00

237 lines
8.4 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// 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(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
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,
},
) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
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",
);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
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 }
: { ...inherited, HOME: mkdtempSync(join(tmpdir(), "cast-home-")) };
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");
});
});