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
684 lines
24 KiB
TypeScript
684 lines
24 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { loadBindings } from "../src/bindings.js";
|
|
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
|
import { loadManifest } from "../src/manifest.js";
|
|
import { decryptSecrets } from "../src/secrets.js";
|
|
import { tmp } from "./helpers/tmp.js";
|
|
|
|
// `cast inventory --emit-draft` against a stub shaped like the box that made it
|
|
// necessary: a Coolify nobody declared, holding our stack under names someone
|
|
// typed, two third-party client sites, and a database cast cannot even model.
|
|
//
|
|
// The single most important assertion in this file is that POISON — the source
|
|
// box's real DATABASE_URL — appears in NO emitted artifact. A draft that carried
|
|
// it would rebuild a box that comes up WORKING, reading and writing the old
|
|
// box's database, and nobody would find out until the old box was deleted.
|
|
|
|
// The live values. If any of these reaches an emitted file, the test fails.
|
|
const POISON =
|
|
"postgres://postgres:s3cr3t@incubator-database-v2.box-b.internal:5432/app";
|
|
const POISON_REDIS = "redis://:r3d1s@incubator-redis.box-b.internal:6379/0";
|
|
const POISON_SERVICE_PW = "umami-generated-9f3a1c";
|
|
// …and one that MUST survive: an ordinary secret is captured, not placeheld.
|
|
const MAILGUN = "key-1a2b3c-real-mailgun";
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
const stubs: Stub[] = [];
|
|
|
|
const ENVS: Record<string, Array<{ key: string; value: string }>> = {
|
|
a1: [
|
|
{ key: "DATABASE_URL", value: POISON },
|
|
{ key: "REDIS_URL", value: POISON_REDIS },
|
|
{ key: "MAILGUN_KEY", value: MAILGUN },
|
|
{ key: "NODE_ENV", value: "production" },
|
|
// Not a name a cast env template can hold. Reported, never dropped in silence.
|
|
{ key: "legacy.flag", value: "on" },
|
|
],
|
|
s1: [
|
|
{ key: "SERVICE_PASSWORD_UMAMI", value: POISON_SERVICE_PW },
|
|
{ key: "SERVICE_FQDN_UMAMI", value: "https://umami.box-b.example.com" },
|
|
{ key: "UMAMI_APP_SECRET", value: "umami-app-secret-xyz" },
|
|
],
|
|
a9: [{ key: "WP_HOME", value: "https://lafamilia.example.com" }],
|
|
};
|
|
|
|
// One project with resources in TWO environments — cast must refuse to pick.
|
|
async function stubCoolify(opts: { ambiguous?: boolean } = {}): 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" });
|
|
// GET /github-apps returns each App's `id` and `name` (cast#72). The
|
|
// incubator app below carries `source_id: 7`, so the binding RESOLVES to
|
|
// this App by the read; the third-party public repo has no GithubApp source
|
|
// and gets a REVIEW marker instead of being wrongly bound to it.
|
|
if (path === "/github-apps")
|
|
return json([{ id: 7, uuid: "g1", name: "hdb-coolify" }]);
|
|
if (path === "/projects")
|
|
return json([
|
|
{ uuid: "p1", name: "Incubator" },
|
|
{ uuid: "p2", name: "La Familia Site" },
|
|
{ uuid: "p3", name: "Martin Reyes Barber Shop" },
|
|
]);
|
|
if (path === "/projects/p1/environments")
|
|
return json([{ name: "production" }, { name: "staging" }]);
|
|
if (path === "/projects/p2/environments")
|
|
return json([{ name: "production" }]);
|
|
if (path === "/projects/p3/environments")
|
|
return json([{ name: "production" }]);
|
|
|
|
// Coolify auto-creates `production`. It is empty (unless the ambiguous
|
|
// variant puts a resource in it too). Everything real lives in `staging`.
|
|
if (path === "/projects/p1/production")
|
|
return json(
|
|
opts.ambiguous
|
|
? { applications: [{ name: "old-core", uuid: "a7", fqdn: "" }] }
|
|
: {},
|
|
);
|
|
if (path === "/projects/p1/staging")
|
|
return json({
|
|
applications: [
|
|
{
|
|
name: "Incubator Stack v2",
|
|
uuid: "a1",
|
|
git_repository: "heavy-duty/incubator",
|
|
git_branch: "main",
|
|
// Cloned by the GitHub App id 7 (hdb-coolify) — source_id/source_type
|
|
// survive serialization, so the binding is resolvable (cast#72).
|
|
source_id: 7,
|
|
source_type: "App\\Models\\GithubApp",
|
|
build_pack: "dockercompose",
|
|
base_directory: "/",
|
|
docker_compose_location: "/docker-compose.yaml",
|
|
docker_compose_domains: JSON.stringify([
|
|
{ name: "core", domain: "https://app.example.com" },
|
|
]),
|
|
destination_id: 3,
|
|
// Basic Auth lives here, and cast's manifest has no field for it.
|
|
custom_labels:
|
|
"traefik.http.middlewares.auth.basicauth.users=admin:$2y$05$x",
|
|
},
|
|
],
|
|
postgresqls: [
|
|
{
|
|
name: "Incubator Database v2",
|
|
uuid: "d1",
|
|
database_type: "standalone-postgresql",
|
|
image: "postgres:16-alpine",
|
|
destination_id: 3,
|
|
},
|
|
],
|
|
services: [
|
|
{
|
|
name: "Incubator Umami",
|
|
uuid: "s1",
|
|
service_type: "umami",
|
|
destination_id: 3,
|
|
},
|
|
],
|
|
// A database cast's manifest cannot express at all.
|
|
mysqls: [{ name: "legacy-analytics", uuid: "m1" }],
|
|
});
|
|
if (path === "/projects/p2/production")
|
|
return json({
|
|
applications: [
|
|
{
|
|
name: "lafamilia-web",
|
|
uuid: "a9",
|
|
git_repository: "https://github.com/third-party/la-familia.git",
|
|
git_branch: "main",
|
|
build_pack: "static",
|
|
base_directory: "/",
|
|
publish_directory: "/dist",
|
|
fqdn: "https://lafamilia.example.com",
|
|
destination_id: 4,
|
|
},
|
|
],
|
|
});
|
|
// A project with no application at all: nothing on the box knows its repo.
|
|
if (path === "/projects/p3/production")
|
|
return json({
|
|
services: [
|
|
{ name: "barber-site", uuid: "s9", service_type: "wordpress" },
|
|
],
|
|
});
|
|
|
|
// The draft's supplementary per-database GET (#75) — the same route
|
|
// diff/apply read (#51). One enabled schedule, saving to S3: the block is
|
|
// draftable, the S3 TARGET (s3_storage_id, an unmappable int) is not.
|
|
if (path === "/databases/d1/backups")
|
|
return json([
|
|
{
|
|
uuid: "sched-1",
|
|
enabled: true,
|
|
save_s3: true,
|
|
frequency: "0 3 * * *",
|
|
database_backup_retention_amount_locally: 7,
|
|
s3_storage_id: 2,
|
|
executions: [],
|
|
},
|
|
]);
|
|
|
|
// The draft's supplementary per-service GET (#83) — the same route the
|
|
// diff's read-back uses. Only the umami answers; the barber shop's s9
|
|
// deliberately 404s below, so the draft's report-not-abort path is what a
|
|
// whole-instance run actually exercises.
|
|
if (path === "/services/s1")
|
|
return json({
|
|
uuid: "s1",
|
|
name: "Incubator Umami",
|
|
applications: [
|
|
{ name: "umami", fqdn: "https://umami.box-b.example.com" },
|
|
{ name: "db", fqdn: null },
|
|
],
|
|
});
|
|
|
|
const envs = path.match(/^\/[a-z]+\/([a-z0-9]+)\/envs$/);
|
|
if (envs) return json(ENVS[envs[1]] ?? []);
|
|
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()));
|
|
});
|
|
|
|
let KEY_FILE = "";
|
|
let RECIPIENT = "";
|
|
|
|
beforeAll(() => {
|
|
const dir = tmp("cast-age-");
|
|
KEY_FILE = join(dir, "key.txt");
|
|
execFileSync("age-keygen", ["-o", KEY_FILE], { stdio: "ignore" });
|
|
RECIPIENT = execFileSync("age-keygen", ["-y", KEY_FILE], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
});
|
|
|
|
function fixture(url: string, opts: { recipient?: string } = {}) {
|
|
const state = tmp("cast-state-");
|
|
writeFileSync(
|
|
join(state, ".coolify.env"),
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
|
);
|
|
writeFileSync(
|
|
join(state, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" prod:",
|
|
" server: box-b",
|
|
" team: { id: 0, name: Root Team }",
|
|
...(opts.recipient ? [` age_recipient: ${opts.recipient}`] : []),
|
|
"github_apps:",
|
|
" heavy-duty/incubator: hdb-coolify",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
const out = join(tmp("cast-out-"), "draft");
|
|
return { state, out };
|
|
}
|
|
|
|
function run(args: string[]): Promise<{ code: number; output: string }> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn("node", ["dist/cli.js", "inventory", ...args], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let output = "";
|
|
child.stdout.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
output += String(d);
|
|
});
|
|
child.stdin.end();
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
});
|
|
}
|
|
|
|
// Every file in the emitted tree, path -> bytes as text.
|
|
function tree(dir: string, prefix = ""): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) Object.assign(out, tree(path, rel));
|
|
else out[rel] = readFileSync(path, "utf8");
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("cast inventory --emit-draft (#27)", () => {
|
|
it("emits the tree: bindings + registry, a manifest per project, templates, stores, UNCAPTURED", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
expect(Object.keys(tree(f.out)).sort()).toEqual([
|
|
"UNCAPTURED.md",
|
|
"environments.yaml",
|
|
// One per project — INCLUDING the two third-party client sites nobody ever
|
|
// declared. They each sit alone in a Coolify-default `production`, and a
|
|
// draft that filtered the instance by one environment name would drop
|
|
// exactly them: the projects the verb exists to bootstrap.
|
|
"incubator/.infra/env/incubator-stack-v2.prod.env.template",
|
|
"incubator/.infra/env/incubator-umami.prod.env.template",
|
|
"incubator/.infra/manifest.yaml",
|
|
"la-familia/.infra/env/lafamilia-web.prod.env.template",
|
|
"la-familia/.infra/manifest.yaml",
|
|
"martin-reyes-barber-shop/.infra/manifest.yaml",
|
|
"secrets/incubator.prod.env.age",
|
|
"secrets/la-familia.prod.env.age",
|
|
]);
|
|
// Every emitted file says what it is, in its own body.
|
|
for (const [path, body] of Object.entries(tree(f.out))) {
|
|
if (path.endsWith(".age")) continue;
|
|
expect(body, path).toContain("PROPOSAL");
|
|
expect(body, path).toContain("apply` does not read");
|
|
}
|
|
});
|
|
|
|
it("NEVER copies a provider-generated value — not into any artifact", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
|
|
// 1. Not in any emitted FILE.
|
|
for (const [path, body] of Object.entries(tree(f.out))) {
|
|
expect(body, path).not.toContain(POISON);
|
|
expect(body, path).not.toContain(POISON_REDIS);
|
|
expect(body, path).not.toContain(POISON_SERVICE_PW);
|
|
}
|
|
// 2. Not in the age store either, once decrypted — the store is the one place
|
|
// a copied value would actually be, and the one place you cannot grep.
|
|
const store = decryptSecrets(
|
|
join(f.out, "secrets", "incubator.prod.env.age"),
|
|
KEY_FILE,
|
|
);
|
|
expect(store.DATABASE_URL).toBe(GENERATED_PLACEHOLDER);
|
|
expect(store.REDIS_URL).toBe(GENERATED_PLACEHOLDER);
|
|
expect(store.SERVICE_PASSWORD_UMAMI).toBe(GENERATED_PLACEHOLDER);
|
|
expect(store.SERVICE_FQDN_UMAMI).toBe(GENERATED_PLACEHOLDER);
|
|
expect(Object.values(store)).not.toContain(POISON);
|
|
// 3. Not on stdout.
|
|
expect(r.output).not.toContain(POISON);
|
|
|
|
// …and an ORDINARY secret IS carried. The discipline is placeholding the
|
|
// provider's names, not refusing to capture anything.
|
|
expect(store.MAILGUN_KEY).toBe(MAILGUN);
|
|
expect(store.UMAMI_APP_SECRET).toBe("umami-app-secret-xyz");
|
|
|
|
// The manifest declares the placeheld names, so a later `capture` placeholds
|
|
// them again with no flag to remember.
|
|
const manifest = readFileSync(
|
|
join(f.out, "incubator", ".infra", "manifest.yaml"),
|
|
"utf8",
|
|
);
|
|
expect(manifest).toContain("generated_secrets:");
|
|
expect(manifest).toContain("- DATABASE_URL");
|
|
expect(manifest).toContain("- REDIS_URL");
|
|
// The plan says so out loud, in names and provenance — never values.
|
|
expect(r.output).toContain("generated");
|
|
expect(r.output).toContain(GENERATED_PLACEHOLDER);
|
|
expect(r.output).toContain("captured");
|
|
});
|
|
|
|
it("UNCAPTURED.md lists every known-inexpressible setting it SAW", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
|
|
|
// Seen on the box, and inexpressible:
|
|
// The umami's hostnames are CAPTURED now (#83) — it has nothing left to
|
|
// report. The service whose per-service GET failed (barber-site) is the
|
|
// one that must be listed, or a rebuild would silently serve nothing.
|
|
expect(md).not.toContain("Incubator Umami");
|
|
expect(md).toContain("barber-site");
|
|
expect(md).toContain("service_domains (hostnames)");
|
|
expect(md).toContain("destination"); // which Docker network (#21)
|
|
expect(md).toContain("destination_id 3");
|
|
expect(md).toContain("legacy-analytics"); // a MySQL cast cannot model
|
|
expect(md).toContain("custom_labels"); // Basic Auth / Traefik labels
|
|
// The schedule itself is CAPTURED now (#75); what stays uncaptured is the
|
|
// S3 target, which reads back only as an unmappable s3_storage_id int.
|
|
expect(md).toContain("backup S3 target");
|
|
expect(md).not.toContain("does not yet read"); // the stale pre-#51 claim
|
|
expect(md).toContain("legacy.flag"); // not a name a template can hold
|
|
|
|
// And the standing sections, emitted on every run whatever was found:
|
|
expect(md).toContain("no API coverage in Coolify 4.1.2");
|
|
expect(md).toContain("Include Source Commit in Build");
|
|
expect(md).toContain("What a rebuild from this draft still cannot restore");
|
|
expect(md).toContain("the GitHub App private key");
|
|
expect(md).toContain("S3 access keys");
|
|
|
|
// The run points at it rather than leaving it to be found.
|
|
expect(r.output).toContain("UNCAPTURED.md");
|
|
});
|
|
|
|
it("reads the backup schedule and emits a real backup block (#75)", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
// The drafted manifest carries the live schedule, in the exact shape the
|
|
// desired side declares — so it diffs clean the moment it is applied.
|
|
const manifest = loadManifest(
|
|
join(f.out, "incubator", ".infra", "manifest.yaml"),
|
|
);
|
|
expect(
|
|
manifest.environments.prod.databases?.["Incubator Database v2"]?.backup,
|
|
).toEqual({ frequency: "0 3 * * *", retention: 7 });
|
|
});
|
|
|
|
it("captures a service's hostnames via the per-service GET (#83)", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
// Projected through the SAME projection the diff's read-back uses, so a
|
|
// drafted service diffs clean the moment it is applied. The container with
|
|
// no fqdn is simply absent, exactly as attachServiceDomains reads it.
|
|
const manifest = loadManifest(
|
|
join(f.out, "incubator", ".infra", "manifest.yaml"),
|
|
);
|
|
expect(
|
|
manifest.environments.prod.services?.["Incubator Umami"]?.service_domains,
|
|
).toEqual({ umami: ["https://umami.box-b.example.com"] });
|
|
|
|
// The barber shop's GET /services/s9 404s. The sweep does not abort: its
|
|
// manifest is still drafted, carries no hostnames, and the loss is NAMED.
|
|
const barber = loadManifest(
|
|
join(f.out, "martin-reyes-barber-shop", ".infra", "manifest.yaml"),
|
|
);
|
|
expect(
|
|
barber.environments.prod.services?.["barber-site"],
|
|
).not.toBeUndefined();
|
|
expect(
|
|
barber.environments.prod.services?.["barber-site"],
|
|
).not.toHaveProperty("service_domains");
|
|
const md = readFileSync(join(f.out, "UNCAPTURED.md"), "utf8");
|
|
expect(md).toContain("barber-site");
|
|
expect(md).toContain("unreachable");
|
|
});
|
|
|
|
it("writes the projects: registry — the list of what exists", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
const yaml = readFileSync(join(f.out, "environments.yaml"), "utf8");
|
|
expect(yaml).toContain("projects:");
|
|
// Read off the application's git remote — the only place a box knows its repo.
|
|
expect(yaml).toContain("third-party/la-familia:");
|
|
expect(yaml).toContain("environments:\n - prod");
|
|
// The bindings the sweep could actually read.
|
|
expect(yaml).toContain("server: box-b");
|
|
expect(yaml).toContain("name: Root Team");
|
|
// Which GitHub App clones a repo is READ from the application's source_id,
|
|
// not guessed (cast#72): the incubator app's source_id: 7 resolves to
|
|
// hdb-coolify, while the third-party PUBLIC repo (no GithubApp source) gets a
|
|
// REVIEW marker rather than being wrongly bound to the only App there is.
|
|
expect(yaml).toContain("github_apps:");
|
|
expect(yaml).toContain("heavy-duty/incubator: hdb-coolify");
|
|
expect(yaml).toMatch(
|
|
/third-party\/la-familia: REVIEW-which-github-app-in-coolify-clones-this-repo/,
|
|
);
|
|
|
|
// The barber shop has no application, so the box knows no repo for it. cast
|
|
// writes the bare project name — and the registry's own parse-time refusal
|
|
// (#25: "a registry key has no meaning without its org") then stops the file
|
|
// being used until a human supplies the org. That refusal IS the design: the
|
|
// alternatives are inventing an org, or dropping a project from the list —
|
|
// and a project missing from the registry is one every fleet run skips in
|
|
// silence.
|
|
expect(yaml).toContain("martin-reyes-barber-shop:");
|
|
const path = join(f.out, "environments.yaml");
|
|
expect(() => loadBindings(path)).toThrow(/has no meaning without its org/);
|
|
});
|
|
|
|
it("refuses --emit-draft together with a repo — that is the reconcile path", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const r = await run([
|
|
"heavy-duty/incubator",
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
]);
|
|
expect(r.code).toBe(2);
|
|
expect(r.output).toContain("SWEEP-mode flag");
|
|
expect(r.output).toContain("Adoption is one-way");
|
|
expect(existsSync(f.out)).toBe(false);
|
|
});
|
|
|
|
it("refuses a target directory that already holds a repo", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
mkdirSync(join(f.out, ".infra"), { recursive: true });
|
|
writeFileSync(
|
|
join(f.out, ".infra", "manifest.yaml"),
|
|
"project: incubator\nenvironments: {}\n",
|
|
);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
expect(r.code).toBe(2);
|
|
expect(r.output).toContain("is not empty");
|
|
// For a declared project the manifest is the truth — a draft must not be able
|
|
// to overwrite a reviewed spec with a live box's accumulated cruft.
|
|
expect(r.output).toContain("Adoption is one-way");
|
|
expect(readFileSync(join(f.out, ".infra", "manifest.yaml"), "utf8")).toBe(
|
|
"project: incubator\nenvironments: {}\n",
|
|
);
|
|
});
|
|
|
|
it("refuses to emit secrets nobody can decrypt — and takes an explicit opt-out", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
const refused = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
]);
|
|
// No --recipient, no age_recipient binding. Silently skipping the store would
|
|
// emit a draft that LOOKS complete and holds not one value.
|
|
expect(refused.code).toBe(2);
|
|
expect(refused.output).toContain("no age recipient");
|
|
expect(refused.output).toContain("--no-secrets");
|
|
expect(existsSync(f.out)).toBe(false);
|
|
|
|
const optOut = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--no-secrets",
|
|
]);
|
|
expect(optOut.code).toBe(0);
|
|
expect(existsSync(join(f.out, "secrets"))).toBe(false);
|
|
expect(
|
|
existsSync(join(f.out, "incubator", ".infra", "manifest.yaml")),
|
|
).toBe(true);
|
|
expect(optOut.output).toContain("NOT WRITTEN");
|
|
});
|
|
|
|
it("takes the recipient from the environment's binding when no flag is given", async () => {
|
|
const f = fixture((await stubCoolify()).url, { recipient: RECIPIENT });
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
]);
|
|
expect(r.code).toBe(0);
|
|
const store = decryptSecrets(
|
|
join(f.out, "secrets", "incubator.prod.env.age"),
|
|
KEY_FILE,
|
|
);
|
|
expect(store.MAILGUN_KEY).toBe(MAILGUN);
|
|
});
|
|
|
|
it("refuses to pick between two environments that both have resources", async () => {
|
|
const f = fixture((await stubCoolify({ ambiguous: true })).url);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
// Picking would emit a blueprint of half a box that says nothing about the
|
|
// other half — and this box is where that already happened once (#22).
|
|
expect(r.code).toBe(2);
|
|
expect(r.output).toContain("MORE THAN ONE environment");
|
|
expect(r.output).toContain("Incubator");
|
|
expect(r.output).toContain("--environment");
|
|
expect(existsSync(f.out)).toBe(false);
|
|
|
|
// …and --environment breaks the tie. For the projects that HAVE no tie (the
|
|
// client sites, each alone in its own `production`), it changes nothing: they
|
|
// are drafted either way.
|
|
const tied = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
"--environment",
|
|
"staging",
|
|
]);
|
|
expect(tied.code).toBe(0);
|
|
const files = Object.keys(tree(f.out));
|
|
expect(files).toContain("incubator/.infra/manifest.yaml");
|
|
expect(files).toContain("la-familia/.infra/manifest.yaml");
|
|
// The environment it did NOT draft is named, not dropped in silence.
|
|
expect(readFileSync(join(f.out, "UNCAPTURED.md"), "utf8")).toContain(
|
|
"other environments",
|
|
);
|
|
});
|
|
|
|
it("still sweeps, and still asserts the team, before it writes anything", async () => {
|
|
const f = fixture((await stubCoolify()).url);
|
|
writeFileSync(
|
|
join(f.state, "environments.yaml"),
|
|
[
|
|
"environments:",
|
|
" prod:",
|
|
" server: box-b",
|
|
" team: { id: 9, name: Some Other Team }",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
const r = await run([
|
|
"--env",
|
|
"prod",
|
|
"--state",
|
|
f.state,
|
|
"--emit-draft",
|
|
f.out,
|
|
"--recipient",
|
|
RECIPIENT,
|
|
]);
|
|
// A wrong-team token reads back an empty instance — and would draft a
|
|
// blueprint of nothing at all, confidently.
|
|
expect(r.code).not.toBe(0);
|
|
expect(existsSync(f.out)).toBe(false);
|
|
});
|
|
});
|