#73/#81 made a service's per-container hostnames settable (urls) and
readable (GET /services/{uuid} -> applications[].fqdn), and diff/apply
carry them as service_domains — but the draft path was never brought
along: the inventory sweep's environment-list GET does not eager-load
service.applications, so --emit-draft emitted every service with no
hostnames and an UNCAPTURED hand-wave.
Now the draft loop makes the same supplementary per-service GET that
diff/apply make (sibling of #75's per-database backups read — one
design, both reads: ungated for DRAFTED resources only, sequential,
per-resource failure degrades to an UNCAPTURED entry instead of
aborting the whole-instance sweep).
The projection is SHARED, not duplicated: projectServiceDomains is
extracted out of attachServiceDomains and exported, so the draft emits
applications[].fqdn through the exact projection + canonicalization
(canonicalizeServiceDomains) the diff's read-back uses — a drafted
manifest diffs clean the moment it is applied. Its two absences stay
distinct: {} is an answer (no hostnames; nothing emitted, nothing
reported), undefined is "not read" — attachServiceDomains still fails
a one-project diff closed on it, while serviceSpec reports it per
resource and keeps sweeping.
The stale "service hostnames" NO_API_COVERAGE row and the
service_domains (hostnames) always-uncaptured entry are gone, and
semantics.md's "does not yet make the per-service GET" line now tells
the truth.
Closes #83
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
685 lines
24 KiB
TypeScript
685 lines
24 KiB
TypeScript
import { execFileSync, spawn } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
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 { loadBindings } from "../src/bindings.js";
|
|
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
|
import { loadManifest } from "../src/manifest.js";
|
|
import { decryptSecrets } from "../src/secrets.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 = mkdtempSync(join(tmpdir(), "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 = mkdtempSync(join(tmpdir(), "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(mkdtempSync(join(tmpdir(), "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);
|
|
});
|
|
});
|