cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
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";
|
|
import { decryptSecrets } from "../src/secrets.js";
|
|
|
|
// End-to-end: the real CLI, a real age identity, a stub Coolify holding real
|
|
// live values. The point is the store that comes out the other side — it is
|
|
// decrypted and asserted on, so "exactly the names the manifest requires, no
|
|
// more and no fewer" is checked against the actual ciphertext rather than
|
|
// against cast's own console output.
|
|
|
|
const SECRETS = {
|
|
// Points at the SOURCE box. Must NOT be carried over.
|
|
DATABASE_URL: "postgres://user:pw@SOURCE-BOX-postgres:5432/app",
|
|
MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET",
|
|
OPENROUTER_API_KEY: "sk-or-REAL-OPENROUTER-SECRET",
|
|
// A real founder. Must NOT be carried over to staging.
|
|
ADMIN_EMAIL: "founder@real-company.com",
|
|
// Live on the box, but the manifest never asks for it.
|
|
UNRELATED_LIVE_VAR: "nobody-asked-for-this",
|
|
};
|
|
|
|
let keyFile: string;
|
|
let recipient: string;
|
|
|
|
beforeAll(() => {
|
|
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
|
keyFile = join(dir, "age-staging.key");
|
|
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
|
const pub = execFileSync("age-keygen", ["-y", keyFile], { encoding: "utf8" });
|
|
recipient = pub.trim();
|
|
});
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
const stubs: Stub[] = [];
|
|
|
|
// A Coolify with one project, one environment, one application carrying the
|
|
// live env above.
|
|
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: "incubator" }]);
|
|
if (path === "/projects/p1/staging")
|
|
return json({ applications: [{ name: "core", uuid: "a1" }] });
|
|
if (path === "/applications/a1/envs")
|
|
return json(
|
|
Object.entries(SECRETS).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:
|
|
generated_secrets: [DATABASE_URL_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
|
|
`;
|
|
|
|
// NODE_ENV is a literal, not a secret — it must not reach the store.
|
|
const TEMPLATE = `NODE_ENV=production
|
|
DATABASE_URL=\${DATABASE_URL_STAGING}
|
|
MAILGUN_API_KEY=\${MAILGUN_API_KEY}
|
|
OPENROUTER_API_KEY=\${OPENROUTER_API_KEY}
|
|
ADMIN_EMAIL=\${ADMIN_EMAIL}
|
|
`;
|
|
|
|
function fixture(url: string, opts: { template?: string } = {}) {
|
|
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
|
writeFileSync(
|
|
join(checkout, ".infra", "env", "core.staging.env.template"),
|
|
opts.template ?? TEMPLATE,
|
|
);
|
|
|
|
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`,
|
|
);
|
|
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 runCapture(
|
|
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", "capture", ...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("cast capture (end to end)", () => {
|
|
it("writes a store with exactly the manifest's names, and the right provenance", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--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);
|
|
|
|
const store = decryptSecrets(f.store, keyFile);
|
|
// No more, no fewer: the four ${...} refs. NODE_ENV is a literal, and
|
|
// UNRELATED_LIVE_VAR is live but unasked-for — neither belongs here.
|
|
expect(Object.keys(store).sort()).toEqual([
|
|
"ADMIN_EMAIL",
|
|
"DATABASE_URL_STAGING",
|
|
"MAILGUN_API_KEY",
|
|
"OPENROUTER_API_KEY",
|
|
]);
|
|
// Captured verbatim.
|
|
expect(store.MAILGUN_API_KEY).toBe(SECRETS.MAILGUN_API_KEY);
|
|
expect(store.OPENROUTER_API_KEY).toBe(SECRETS.OPENROUTER_API_KEY);
|
|
// Generated: the placeholder, NEVER the source box's own database URL.
|
|
expect(store.DATABASE_URL_STAGING).toBe("pending-coolify-generated");
|
|
expect(store.DATABASE_URL_STAGING).not.toContain("SOURCE-BOX");
|
|
// Overridden: the operator's value, not the real founder's address.
|
|
expect(store.ADMIN_EMAIL).toBe("operator@example.com");
|
|
expect(store.ADMIN_EMAIL).not.toBe(SECRETS.ADMIN_EMAIL);
|
|
});
|
|
|
|
// "No secret value is ever written to stdout" — checked against the real
|
|
// values the stub served, on the real console output of a real run.
|
|
it("never prints a secret value to the console", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "staging\n",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
expect(r.code).toBe(0);
|
|
for (const value of Object.values(SECRETS)) {
|
|
expect(r.output).not.toContain(value);
|
|
}
|
|
expect(r.output).not.toContain("operator@example.com");
|
|
// It did print the NAMES, though — that is the plan.
|
|
expect(r.output).toContain("MAILGUN_API_KEY");
|
|
expect(r.output).toContain("captured");
|
|
expect(r.output).toContain("generated");
|
|
expect(r.output).toContain("overridden");
|
|
});
|
|
|
|
// The plaintext exists only in memory and on age's stdin.
|
|
it("leaves no plaintext behind — the store is real ciphertext", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "staging\n",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
const raw = readFileSync(f.store, "utf8");
|
|
expect(raw).toContain("age-encryption.org");
|
|
for (const value of Object.values(SECRETS)) {
|
|
expect(raw).not.toContain(value);
|
|
}
|
|
});
|
|
|
|
// A name required by the template but absent from the source refuses the run
|
|
// — writing an empty would boot the app misconfigured, plausibly.
|
|
it("refuses when a required name is absent from the source", async () => {
|
|
const f = fixture((await stubCoolify()).url, {
|
|
template: `${TEMPLATE}TURNSTILE_SECRET=\${TURNSTILE_SECRET}\n`,
|
|
});
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "staging\n",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/TURNSTILE_SECRET\s+MISSING/);
|
|
expect(r.output).toMatch(/refusing to write the store/);
|
|
expect(existsSync(f.store)).toBe(false);
|
|
});
|
|
|
|
// The confirmation is the last gate, and it is not "y".
|
|
it("aborts, writing nothing, when the confirmation does not name the env", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "y\n",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/aborted/);
|
|
expect(existsSync(f.store)).toBe(false);
|
|
});
|
|
|
|
it("aborts on a closed stdin rather than hanging", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/aborted/);
|
|
});
|
|
|
|
// An override's VALUE never comes from argv — argv is visible in `ps`.
|
|
it("refuses an --override whose CAST_CAPTURE_<NAME> is unset", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "staging\n",
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/CAST_CAPTURE_ADMIN_EMAIL/);
|
|
expect(r.output).toMatch(/never from the command/);
|
|
});
|
|
|
|
// The store may hold the only copy of values the source box no longer has.
|
|
it("refuses to overwrite an existing store without --force", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
writeFileSync(f.store, "PRE-EXISTING");
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
stdin: "staging\n",
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/already exists/);
|
|
expect(r.output).toMatch(/--force/);
|
|
expect(readFileSync(f.store, "utf8")).toBe("PRE-EXISTING");
|
|
});
|
|
|
|
it("refuses an environment with no age_recipient", async () => {
|
|
const f = fixture((await stubCoolify()).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 runCapture(base(f), { stdin: "staging\n" });
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/age_recipient/);
|
|
});
|
|
|
|
// Same position as diff: capture is only ever a claim about something that
|
|
// already exists. Against an absent target it would call every secret
|
|
// "missing" — an alarming report about the wrong box.
|
|
it("refuses an absent target rather than reporting every secret missing", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await runCapture([...base(f), "--project", "typo"], {
|
|
stdin: "staging\n",
|
|
});
|
|
expect(r.code).not.toBe(0);
|
|
expect(r.output).toMatch(/refusing to capture/);
|
|
expect(r.output).toMatch(/no project named "typo"/);
|
|
expect(r.output).not.toMatch(/MISSING/);
|
|
});
|
|
});
|