diff --git a/README.md b/README.md index f5243f3..93ba16b 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,10 @@ Pass it with `--state `, or set `CAST_STATE`. Defaults to the cwd. ## Commands ```sh -cast apply / --env [--path ] [--hostname-overlay ] -cast diff / --env [--full] -cast capture / --env [--generated ] [--override ] +cast apply / --env [--path ] [--hostname-overlay ] +cast diff / --env [--full] +cast capture / --env [--generated ] [--override ] +cast inventory / --env cast server add --ip --key --env [--user root] [--port 22] cast smoke --env cast team [--env ] @@ -75,6 +76,12 @@ cast team [--env ] default branch). - **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full` also compares env vars. Exits non-zero when dirty, so CI can gate on it. +- **`inventory`** — what is actually *on* a box, and how it lines up with the + manifest: resources and env var **keys** (never values), sorted into + on-both / manifest-only / box-only. Needs no store, no age key, and no + recipient — it runs *before* adoption, which is the point of it. A document, + read by a person; nothing here is consumed by `apply`. See *Adopting a + hand-built instance*. - **`capture`** — the adoption path: reads a hand-built instance's live env and writes the environment's age store from it. See *Adopting a hand-built instance* below. @@ -155,8 +162,26 @@ assert. It is the most consequential input to any run, and the least visible. ## Adopting a hand-built instance cast is otherwise scoped to the steady state: manifest → Coolify, forever. -`capture` is the one-way-in — it bootstraps an environment's age store from an -instance that was built by hand, before any manifest existed. +Adoption is the one way in, and it has two verbs and a fixed order: + +**`inventory` → you read it → a manifest PR → `capture` → `apply`** + +**Look before you adopt.** A box nobody declared does not use your vocabulary: +its project is called whatever someone typed, its environment is Coolify's +default (`production`, not `prod`), and its resources are named by whoever +clicked *New Resource* that afternoon. `inventory` shows you both sides at once, +so those differences arrive together, as a document — instead of one at a time, +as refusals from a verb that is already halfway through a migration. + +```sh +cast inventory heavy-duty/incubator --env prod --instance legacy \ + --project Incubator --environment production +``` + +It never reads a value, needs no store and no key, and its output is **not** +desired state. What you do with it is decide, resource by resource and key by +key, what the manifest should *gain* and what is cruft that must not travel — +and land that as a manifest PR. Only then: ```sh CAST_CAPTURE_ADMIN_EMAIL=me@example.com \ @@ -178,6 +203,32 @@ values off the instance, and classifies every name: Then it prints a plan of **names and provenance — never values** — and waits for you to type the environment's name. +Before any of that, it checks that the resources the manifest names **exist**. +An absent resource reads back exactly like one with no env vars set: every name +it declares reports *missing*, and `--override` would then have you hand-carry +values that are sitting right there under a different name — writing a perfectly +valid store while the actual finding (the manifest and the box disagree about +what this thing is called) is never discovered. So a resource that isn't there +refuses, and names what is. `inventory` is how you reconcile it. + +### Three names that are not yours + +A hand-built box names things without asking you, at three levels, and cast takes +each as a coordinate to *read* with — never as a reason to rename anything of +yours: + +| flag | when | +| --- | --- | +| `--project ` | the project isn't named after the repo (`Incubator`, not `incubator`) | +| `--environment ` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) | +| — | a resource isn't named after the manifest's → **refuses**; reconcile with `inventory` first | + +`--env` stays **ours**: it selects the manifest block, the `environments.yaml` +binding, the age key, the store path. `--environment` is *theirs*, on the wire, +and nothing else. Collapsing the two lets a box that is being deleted next week +name the environment of the box that replaces it — `apply` creates the +environment from that value, so it would be inherited permanently. + The mapping is not mechanical, and that is the whole design. A `DATABASE_URL` copied off the source box points at the *source box's* Postgres: confidently wrong, entirely plausible, and the target's real URL does not exist until Coolify diff --git a/src/capture.ts b/src/capture.ts index 958dcfc..f12dc85 100644 --- a/src/capture.ts +++ b/src/capture.ts @@ -33,6 +33,54 @@ export type Classification = { conflicts: Array<{ ref: string; values: Site[] }>; }; +// The manifest resources that no resource of that name exists for on the source. +// +// This is the absent-target lie (#12/D-237) one level deeper in the tree, and it +// fails in exactly the same way: a resource that is ABSENT reads back +// identically to one that is PRESENT with no env vars set. Every name it +// declares reports MISSING, the run refuses, and the message tells the operator +// to supply each of them with --override — which would "work", writing a +// perfectly valid store, while the real finding (the manifest and the box +// disagree about what this resource is called) is never discovered and the +// operator hand-carries values that were sitting right there under another name. +// +// So: refuse on the RESOURCE first, and only report per-name MISSING for +// resources that were actually found — where it means what it says. +export function absentResources( + required: RequiredSecret[], + liveNames: Iterable, +): string[] { + const live = new Set(liveNames); + const declared = new Set(required.map((r) => r.resource)); + return [...declared].filter((name) => !live.has(name)).sort(); +} + +export function renderAbsentResources( + absent: string[], + live: Array<{ kind: string; name: string }>, + ctx: { project: string; environment: string }, +): string { + const width = Math.max(0, ...live.map((l) => l.kind.length)); + return [ + `refusing to capture: the manifest declares ${absent.length} resource(s) that do not exist here`, + "", + ` looked in: project "${ctx.project}", environment "${ctx.environment}"`, + ` looked for: ${absent.join(", ")}`, + " exists here:", + ...(live.length > 0 + ? live.map((l) => ` ${l.kind.padEnd(width)} ${l.name}`) + : [" (nothing at all)"]), + "", + "A resource that is absent reads back exactly like one with no env vars set:", + "every name it declares reports MISSING, and --override would then have you", + "hand-carry values that are sitting right there under a different name. The", + "finding is not that the secrets are missing — it is that the manifest and this", + "box disagree about what these resources are called.", + "", + "Reconcile the names first (`cast inventory` shows both sides), then capture.", + ].join("\n"); +} + function groupByRef(required: RequiredSecret[]): Map { const byRef = new Map(); for (const { ref, resource, key } of required) { diff --git a/src/cli.ts b/src/cli.ts index 56039ac..f7a76c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,9 +6,16 @@ import { parseArgs } from "node:util"; import { parse as parseYaml } from "yaml"; import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js"; import { githubAppNameFor, loadBindings } from "./bindings.js"; -import { type LiveEnvs, classify, renderCapturePlan } from "./capture.js"; +import { + type LiveEnvs, + absentResources, + classify, + renderAbsentResources, + renderCapturePlan, +} from "./capture.js"; import { type CoolifyInstance, + DEFAULT_INSTANCE, assertWritable, formatInstance, loadInstance, @@ -21,8 +28,10 @@ import { renderDiff, } from "./diff.js"; import { assertEnvVarPolicy } from "./envtemplate.js"; +import { type LiveResource, reconcile, renderInventory } from "./inventory.js"; import { desiredFromManifest, + manifestResources, requiredSecrets, resolveCheckout, } from "./resolve.js"; @@ -36,9 +45,10 @@ import { serverAdd } from "./server.js"; import { smoke } from "./smoke.js"; import { assertTeam, formatTeam } from "./team.js"; -const USAGE = `usage: cast apply / --env [--path ] [--project ] [--hostname-overlay ] - cast diff / --env [--full] [--project ] - cast capture / --env [--path ] [--project ] [--generated ] [--override ] [--force] +const USAGE = `usage: cast apply / --env [--path ] [--project ] [--environment ] [--hostname-overlay ] + cast diff / --env [--full] [--project ] [--environment ] + cast capture / --env [--path ] [--project ] [--environment ] [--generated ] [--override ] [--force] + cast inventory / --env [--path ] [--project ] [--environment ] cast server add --ip --key --env [--user root] [--port 22] cast smoke --env cast team [--env ] @@ -63,6 +73,13 @@ const USAGE = `usage: cast apply / --env [--path ] [--pr whatever someone typed; \`diff\` refuses rather than reporting an absent project as an empty one, and this is how you point it at the real name. + --environment + the Coolify environment to act on, when it is not named after + --env (the default). Same problem as --project, one level down: + a box built by hand has whatever Coolify defaulted to, which is + \`production\`, not \`prod\`. This changes ONLY the name on the + wire — --env still selects the manifest block, the + environments.yaml binding, the age key and the store path. capture (adopt a hand-built instance into the age secret store): --generated force NAME to the \`pending-coolify-generated\` placeholder, @@ -311,7 +328,12 @@ export async function fetchLive( // exists next to it. export function renderAbsentTarget( lookup: Extract, - ctx: { orgRepo: string; overridden: boolean; verb?: string }, + ctx: { + orgRepo: string; + overridden: boolean; + envOverridden?: boolean; + verb?: string; + }, ): string { // `capture` takes the same position as `diff`, and for the same reason: it // is only ever a claim about something that already exists. Against an @@ -321,6 +343,7 @@ export function renderAbsentTarget( const origin = ctx.overridden ? "--project" : `derived from the repo slug ${ctx.orgRepo}`; + const envOrigin = ctx.envOverridden ? "--environment" : "derived from --env"; const head = lookup.missing === "project" ? [ @@ -332,10 +355,10 @@ export function renderAbsentTarget( : [ `refusing to ${verb}: project "${lookup.project}" has no environment "${lookup.environment}"`, "", - ` looked for: environment "${lookup.environment}" in project "${lookup.project}"`, - " note: cast names environments after --env, so a project built by", - " hand in the Coolify UI may well use a different name for the", - " same tier (Coolify's own default is `production`).", + ` looked for: environment "${lookup.environment}" in project "${lookup.project}" (${envOrigin})`, + " note: a project built by hand in the Coolify UI may well use a", + " different name for the same tier — Coolify's own default is", + " `production`, not `prod`.", ]; return [ ...head, @@ -347,7 +370,12 @@ export function renderAbsentTarget( "", lookup.missing === "project" ? "Pass --project if this instance names it differently." - : "Re-run with --env naming the environment as it exists here.", + : // NOT "rename your environment to match the box". The box does not get to + // name our environments: --env selects the manifest block, the binding, the + // age key and the store path, and a hand-built box being evicted next week + // must not decide any of them. --environment is the coordinate for reading + // it, and it changes nothing on our side of the line. + "Pass --environment if this instance names it differently.", ].join("\n"); } @@ -439,6 +467,7 @@ async function main(): Promise { path: { type: "string" }, state: { type: "string" }, project: { type: "string" }, + environment: { type: "string" }, instance: { type: "string" }, "hostname-overlay": { type: "string" }, full: { type: "boolean", default: false }, @@ -458,6 +487,14 @@ async function main(): Promise { // whatever someone typed. --project overrides that one, and nothing else — // secrets stay keyed by the repo (a state-repo convention we own). const projectName = values.project ?? repoShort; + // Exactly the same split, one level down. `--env` is OUR name for the + // environment: it selects the manifest block, the environments.yaml + // binding, the age key, the store path, the team to assert. `--environment` + // is THEIR name for it on the wire, and nothing else. Collapsing the two + // (as cast did until now) means a box built by hand in someone's UI gets to + // name our environment — and since apply creates the environment from this + // value, a legacy box's accident would be inherited by the new one forever. + const coolifyEnv = values.environment ?? envName; const checkout = resolveCheckout(orgRepo, { env: envName, path: values.path, @@ -499,7 +536,7 @@ async function main(): Promise { const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); const mode = command === "apply" || values.full ? "full" : "structural"; - const lookup = await fetchLive(client, projectName, envName); + const lookup = await fetchLive(client, projectName, coolifyEnv); // apply and diff take opposite (and both correct) positions on absence: // apply is *allowed* to be the thing that brings a project into existence, // so [] is a legitimate starting point. diff is only ever a claim about @@ -511,6 +548,7 @@ async function main(): Promise { renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, + envOverridden: values.environment !== undefined, }), ); return 2; @@ -530,7 +568,10 @@ async function main(): Promise { ); const exec = buildExecutor(client, { projectName, - envName, + // The name the environment gets ON COOLIFY when apply creates it — so an + // apply that adopts an existing hand-named environment writes into that + // one, rather than creating a second environment beside it. + envName: coolifyEnv, serverUuid, githubAppUuid, s3DestinationUuid: binding.s3_destination, @@ -553,6 +594,7 @@ async function main(): Promise { state: { type: "string" }, path: { type: "string" }, project: { type: "string" }, + environment: { type: "string" }, instance: { type: "string" }, generated: { type: "string", multiple: true }, override: { type: "string", multiple: true }, @@ -568,6 +610,11 @@ async function main(): Promise { const stateDir = stateDirFrom(values.state); const repoShort = orgRepo.split("/")[1]; const projectName = values.project ?? repoShort; + // Their name for the environment, on the wire. The store below stays keyed + // by OUR name (--env) — capture is the verb most likely to be pointed at a + // hand-built box, and the store it writes must not inherit that box's + // vocabulary. + const coolifyEnv = values.environment ?? envName; const store = secretsFileFor(stateDir, repoShort, envName); // Never overwrite a store by accident. `apply` never deletes; the verb // that WRITES the store gets the same disposition, because the thing it @@ -623,22 +670,43 @@ async function main(): Promise { // as "every secret is missing" against a box that is fine. const team = await assertTeam(client, binding.team, envName); console.log(`team ${formatTeam(team)} ✓`); - const lookup = await fetchLive(client, projectName, envName); + const lookup = await fetchLive(client, projectName, coolifyEnv); if (!lookup.found) { console.error( renderAbsentTarget(lookup, { orgRepo, overridden: values.project !== undefined, + envOverridden: values.environment !== undefined, verb: "capture", }), ); return 2; } + // Databases hold no manifest-templated env of their own — their URL is what + // the APPS reference, and that name is generated, not captured. + const envBearing = lookup.live.filter((l) => l.kind !== "database"); + // Before reading a single env: does every resource the manifest requires a + // secret FROM actually exist here? An absent resource reads back exactly + // like one with no env vars set — every name it declares reports MISSING — + // and the suggested remedy for MISSING (--override) would then have the + // operator hand-carry values that are sitting right there under a different + // name, burying the real finding. Same lie as the absent project, one level + // deeper. See absentResources. + const absent = absentResources( + required, + envBearing.map((l) => l.name), + ); + if (absent.length > 0) { + console.error( + renderAbsentResources(absent, lookup.live, { + project: projectName, + environment: coolifyEnv, + }), + ); + return 2; + } const liveEnvs: LiveEnvs = {}; - for (const l of lookup.live) { - // Databases hold no manifest-templated env of their own — their URL is - // what the APPS reference, and that name is generated, not captured. - if (l.kind === "database") continue; + for (const l of envBearing) { liveEnvs[l.name] = await fetchEnv(client, l); } const classification = classify( @@ -677,6 +745,81 @@ async function main(): Promise { ); return 0; } + if (command === "inventory") { + const { values, positionals } = parseArgs({ + args: rest, + allowPositionals: true, + options: { + env: { type: "string" }, + state: { type: "string" }, + path: { type: "string" }, + project: { type: "string" }, + environment: { type: "string" }, + instance: { type: "string" }, + }, + }); + const orgRepo = positionals[0]; + const envName = values.env; + if (!orgRepo || !envName) { + console.error(USAGE); + return 2; + } + const stateDir = stateDirFrom(values.state); + const repoShort = orgRepo.split("/")[1]; + const projectName = values.project ?? repoShort; + const coolifyEnv = values.environment ?? envName; + const bindings = loadBindings(join(stateDir, "environments.yaml")); + const binding = bindings.environments[envName]; + if (!binding) { + console.error(`environment ${envName} not in environments.yaml`); + return 2; + } + // Deliberately NOT resolveCheckout's prod ban, no secrets, no age key, no + // age_recipient: inventory runs BEFORE any store exists — that is the whole + // point of it — and it reads nothing it could leak. A read token is enough. + const checkout = resolveCheckout(orgRepo, { + env: envName, + path: values.path, + }); + const manifest = manifestResources(checkout, envName); + const { client } = openCoolify(stateDir, values.instance, binding); + // Read-only instances are exactly what this verb is for. It still takes the + // team assert: a wrong-team token reads back nothing, and "nothing" would + // render here as "the box is empty" — the same lie, dressed as a report. + const team = await assertTeam(client, binding.team, envName); + console.log(`team ${formatTeam(team)} ✓`); + const lookup = await fetchLive(client, projectName, coolifyEnv); + if (!lookup.found) { + console.error( + renderAbsentTarget(lookup, { + orgRepo, + overridden: values.project !== undefined, + envOverridden: values.environment !== undefined, + verb: "inventory", + }), + ); + return 2; + } + const live: LiveResource[] = []; + for (const l of lookup.live) { + // Keys, never values — see renderInventory. Databases carry no env of + // their own worth reconciling (their URL is what the apps reference). + const envKeys = + l.kind === "database" ? [] : Object.keys(await fetchEnv(client, l)); + live.push({ kind: l.kind, name: l.name, envKeys }); + } + console.log( + renderInventory(reconcile(manifest, live), { + orgRepo, + env: envName, + instance: values.instance ?? binding.instance ?? DEFAULT_INSTANCE, + project: projectName, + environment: coolifyEnv, + }), + ); + // Always 0: this is a report, not a gate. `diff` is the gate. + return 0; + } if (command === "server" && rest[0] === "add") { const { values, positionals } = parseArgs({ args: rest.slice(1), diff --git a/src/envtemplate.ts b/src/envtemplate.ts index 0c2f5b5..7e3ba9f 100644 --- a/src/envtemplate.ts +++ b/src/envtemplate.ts @@ -61,6 +61,17 @@ export function templateRefs( ); } +// Every env var key a template declares — refs and literals alike. `capture` +// only cares about the ${...} refs (the secrets); `inventory` needs all of them, +// because the question it answers is "what does the manifest put on this +// resource, and what does the box actually have?", and a literal the manifest +// sets (a feature flag, NODE_ENV) is just as much a difference as a secret. +// +// Same parser as everything else in this file — see parseTemplate. +export function templateKeys(text: string): string[] { + return parseTemplate(text).map(({ key }) => key); +} + // An environment may forbid variables by name pattern, declared as // `environments..forbidden_var_patterns` in the state repo. The rule is // PRESENCE, not value: a forbidden var set to "false" still refuses the apply, diff --git a/src/inventory.ts b/src/inventory.ts new file mode 100644 index 0000000..9effe35 --- /dev/null +++ b/src/inventory.ts @@ -0,0 +1,173 @@ +import type { ManifestResource } from "./resolve.js"; + +// `inventory` answers the question every other verb assumes you already +// answered: **what is actually on this box?** +// +// cast can describe a Coolify it built (`diff`), change one (`apply`), and take +// secret values off one for names a manifest already declares (`capture`). None +// of those can tell you what is on a box you did NOT build — and that is the +// first thing anyone needs when adopting an existing deployment. Without it, +// every mismatch surfaces later, one at a time, as a refusal from a verb that is +// already committed to a course of action; and the tempting fix for a refusal is +// to bend the manifest toward the legacy box, which is exactly backwards. +// +// The output is a DOCUMENT, read by a person. It is deliberately not desired +// state, not a store, and not consumed by `apply`: +// +// inventory → human reads → manifest PR → capture → apply +// +// That boundary is what keeps `capture` safe to be strict about. `inventory` may +// read everything, because a person reads its output. `capture` may only ever +// write what the manifest declares, because `apply` reads its output. Same box, +// two consumers, two contracts. + +export type LiveResource = { + kind: string; + name: string; + envKeys: string[]; +}; + +export type Matched = { + kind: string; + name: string; + // Declared by the manifest, absent from the box. + manifestOnlyKeys: string[]; + // On the box, and the manifest knows nothing about it. Either something the + // manifest must gain, or cruft that must not travel — and only a human can + // say which. That judgment is the whole reason this verb exists. + boxOnlyKeys: string[]; + sharedKeys: string[]; +}; + +export type Reconciliation = { + matched: Matched[]; + manifestOnly: ManifestResource[]; + boxOnly: LiveResource[]; +}; + +const sorted = (xs: Iterable) => [...xs].sort(); + +export function reconcile( + manifest: ManifestResource[], + live: LiveResource[], +): Reconciliation { + // Matched by NAME, not by kind: a manifest `application` that the box models + // as a `service` is a real and interesting finding, and collapsing it into + // "manifest-only + box-only" would hide the fact that they are the same thing. + const liveByName = new Map(live.map((l) => [l.name, l])); + const matched: Matched[] = []; + const manifestOnly: ManifestResource[] = []; + for (const m of manifest) { + const l = liveByName.get(m.name); + if (!l) { + manifestOnly.push(m); + continue; + } + const boxKeys = new Set(l.envKeys); + const manifestKeys = new Set(m.envKeys); + matched.push({ + kind: m.kind === l.kind ? m.kind : `${m.kind} / ${l.kind} on the box`, + name: m.name, + manifestOnlyKeys: sorted(m.envKeys.filter((k) => !boxKeys.has(k))), + boxOnlyKeys: sorted(l.envKeys.filter((k) => !manifestKeys.has(k))), + sharedKeys: sorted(m.envKeys.filter((k) => boxKeys.has(k))), + }); + } + const manifestNames = new Set(manifest.map((m) => m.name)); + const boxOnly = live.filter((l) => !manifestNames.has(l.name)); + return { matched, manifestOnly, boxOnly }; +} + +// Names and keys. NEVER values — the whole artifact is meant to be read, pasted, +// and committed to a PR discussion, so it must be safe to do all three with. +export function renderInventory( + rec: Reconciliation, + ctx: { + orgRepo: string; + env: string; + instance: string; + project: string; + environment: string; + }, +): string { + const lines = [ + `inventory — ${ctx.orgRepo} ${ctx.env}`, + "", + ` source: instance ${ctx.instance}`, + ` project: ${ctx.project}`, + ` environment: ${ctx.environment}`, + "", + " Env var KEYS only — no values are read or printed.", + "", + ]; + + const bullet = (kind: string, name: string) => + ` ${kind.padEnd(12)} ${name}`; + + lines.push("on the box, and in the manifest"); + if (rec.matched.length === 0) { + lines.push(" (nothing matched — see both lists below)"); + } + for (const m of rec.matched) { + lines.push(bullet(m.kind, m.name)); + if (m.sharedKeys.length > 0) { + lines.push(` both: ${m.sharedKeys.join(", ")}`); + } + if (m.manifestOnlyKeys.length > 0) { + lines.push(` manifest only: ${m.manifestOnlyKeys.join(", ")}`); + } + if (m.boxOnlyKeys.length > 0) { + lines.push(` box only: ${m.boxOnlyKeys.join(", ")}`); + } + } + + lines.push("", "in the manifest, NOT on the box"); + if (rec.manifestOnly.length === 0) lines.push(" (none)"); + for (const m of rec.manifestOnly) { + lines.push(bullet(m.kind, m.name)); + if (m.envKeys.length > 0) { + lines.push(` declares: ${sorted(m.envKeys).join(", ")}`); + } + } + + lines.push("", "on the box, NOT in the manifest"); + if (rec.boxOnly.length === 0) lines.push(" (none)"); + for (const l of rec.boxOnly) { + lines.push(bullet(l.kind, l.name)); + // A resource the manifest has never heard of: EVERY key on it is box-only, + // and they are the most interesting keys in the report — this is where a + // resource that the manifest calls something else shows up, carrying the + // values `capture` went looking for and could not find. + if (l.envKeys.length > 0) { + lines.push(` carries: ${sorted(l.envKeys).join(", ")}`); + } + } + + const drift = + rec.manifestOnly.length + + rec.boxOnly.length + + rec.matched.reduce( + (n, m) => n + m.manifestOnlyKeys.length + m.boxOnlyKeys.length, + 0, + ); + lines.push( + "", + drift === 0 + ? "The manifest and this box name the same things. (On a hand-built box, treat" + : `${drift} difference(s) between the manifest and this box.`, + ); + if (drift === 0) { + lines.push( + "that with suspicion rather than relief — a box nobody declared agreeing", + "perfectly with a manifest nobody applied is more often a wrong lookup than", + "a true match.)", + ); + } + lines.push( + "", + "This is a document, not desired state. Nothing here is read by `apply` — the", + "path from here is: decide what the manifest should GAIN and what is cruft that", + "must not travel, land that as a manifest PR, then `capture` and `apply`.", + ); + return lines.join("\n"); +} diff --git a/src/resolve.ts b/src/resolve.ts index 799261a..4ad6c51 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -6,6 +6,7 @@ import type { Desired } from "./diff.js"; import { type ResolvedEnv, resolveTemplate, + templateKeys, templateRefs, } from "./envtemplate.js"; import { loadManifest } from "./manifest.js"; @@ -245,6 +246,55 @@ export function requiredSecrets( return { required, generated }; } +// What the manifest declares for an environment, as names only — no secrets, no +// age key, no store. `inventory` runs BEFORE any of those exist (that is the +// point of it: you read the box before you can possibly have adopted it), so it +// must be able to describe the manifest side without resolving a single value. +export type ManifestResource = { + kind: "application" | "database" | "service"; + name: string; + envKeys: string[]; +}; + +export function manifestResources( + checkoutDir: string, + envName: string, +): ManifestResource[] { + const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml")); + const envSpec = manifest.environments[envName]; + if (!envSpec) { + throw new Error( + `environment ${envName} not in manifest (has: ${Object.keys(manifest.environments).join(", ") || "none"})`, + ); + } + const keysOf = (resource: string, template?: string): string[] => { + if (!template) return []; + const file = join(checkoutDir, ".infra", "env", template); + if (!existsSync(file)) + throw new Error( + `env template missing: ${file} (referenced by ${resource})`, + ); + return templateKeys(readFileSync(file, "utf8")); + }; + return [ + ...Object.entries(envSpec.applications).map(([name, app]) => ({ + kind: "application" as const, + name, + envKeys: keysOf(name, app.env_template), + })), + ...Object.entries(envSpec.databases ?? {}).map(([name]) => ({ + kind: "database" as const, + name, + envKeys: [], + })), + ...Object.entries(envSpec.services ?? {}).map(([name, svc]) => ({ + kind: "service" as const, + name, + envKeys: keysOf(name, svc.env_template), + })), + ]; +} + export function desiredFromManifest( checkoutDir: string, envName: string, diff --git a/test/inventory.test.ts b/test/inventory.test.ts new file mode 100644 index 0000000..7b2b5b9 --- /dev/null +++ b/test/inventory.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { absentResources } from "../src/capture.js"; +import { reconcile, renderInventory } from "../src/inventory.js"; +import type { ManifestResource } from "../src/resolve.js"; + +const manifest: ManifestResource[] = [ + { + kind: "application", + name: "core", + envKeys: ["NODE_ENV", "DATABASE_URL", "MAILGUN_API_KEY"], + }, + { kind: "service", name: "umami", envKeys: ["APP_SECRET"] }, + { kind: "database", name: "postgres", envKeys: [] }, +]; + +describe("reconcile", () => { + it("sorts resources into both / manifest-only / box-only", () => { + const rec = reconcile(manifest, [ + { kind: "application", name: "core", envKeys: ["NODE_ENV"] }, + { kind: "application", name: "landing", envKeys: [] }, + ]); + expect(rec.matched.map((m) => m.name)).toEqual(["core"]); + expect(rec.manifestOnly.map((m) => m.name)).toEqual(["umami", "postgres"]); + expect(rec.boxOnly.map((l) => l.name)).toEqual(["landing"]); + }); + + it("splits a matched resource's env KEYS the same three ways", () => { + const rec = reconcile(manifest, [ + { + kind: "application", + name: "core", + envKeys: ["NODE_ENV", "MAILGUN_API_KEY", "LEFTOVER_FROM_2019"], + }, + ]); + const core = rec.matched[0]; + expect(core.sharedKeys).toEqual(["MAILGUN_API_KEY", "NODE_ENV"]); + // The manifest wants it; the box has never heard of it. + expect(core.manifestOnlyKeys).toEqual(["DATABASE_URL"]); + // On the box, unknown to the manifest. Either the manifest must gain it or + // it is cruft that must not travel — the judgment this verb exists to serve. + expect(core.boxOnlyKeys).toEqual(["LEFTOVER_FROM_2019"]); + }); + + it("matches by name across a kind mismatch rather than hiding it", () => { + // A manifest `service` the box models as an `application` is the same thing + // under two vocabularies. Reporting it as manifest-only + box-only would + // read as "two unrelated resources", which is exactly the wrong conclusion. + const rec = reconcile( + [{ kind: "service", name: "umami", envKeys: [] }], + [{ kind: "application", name: "umami", envKeys: [] }], + ); + expect(rec.manifestOnly).toEqual([]); + expect(rec.boxOnly).toEqual([]); + expect(rec.matched[0].kind).toContain("service"); + expect(rec.matched[0].kind).toContain("application"); + }); +}); + +describe("renderInventory", () => { + const ctx = { + orgRepo: "heavy-duty/incubator", + env: "prod", + instance: "box-b", + project: "Incubator", + environment: "production", + }; + + it("names both sides and every bucket", () => { + const out = renderInventory( + reconcile(manifest, [ + { + kind: "application", + name: "incubator-stack", + envKeys: ["MAILGUN_API_KEY"], + }, + ]), + ctx, + ); + // The two names that are NOT ours — the coordinates that made this readable. + expect(out).toContain("Incubator"); + expect(out).toContain("production"); + // Declared, absent. + expect(out).toContain("core"); + // Present, undeclared — the finding that a MISSING-per-name report buries. + expect(out).toContain("incubator-stack"); + expect(out).toContain("This is a document, not desired state"); + }); + + it("treats a zero-drift hand-built box as suspicious, not as a pass", () => { + const out = renderInventory( + reconcile( + [{ kind: "application", name: "core", envKeys: [] }], + [{ kind: "application", name: "core", envKeys: [] }], + ), + ctx, + ); + expect(out).toContain("suspicion rather than relief"); + }); +}); + +describe("absentResources", () => { + const required = [ + { ref: "MAILGUN_API_KEY", resource: "core", key: "MAILGUN_API_KEY" }, + { ref: "DATABASE_URL_PROD", resource: "core", key: "DATABASE_URL" }, + { ref: "UMAMI_APP_SECRET", resource: "umami", key: "APP_SECRET" }, + ]; + + it("names the manifest resources the box does not have", () => { + expect(absentResources(required, ["incubator-stack", "umami"])).toEqual([ + "core", + ]); + }); + + it("is empty when every declaring resource exists", () => { + expect(absentResources(required, ["core", "umami", "landing"])).toEqual([]); + }); + + it("reports each absent resource once, not once per secret it declares", () => { + // `core` declares two of the three refs. The old failure reported one + // MISSING per NAME (15 of them, in the live incident) and buried the single + // fact that mattered: one resource is called something else here. + expect(absentResources(required, [])).toEqual(["core", "umami"]); + }); +}); diff --git a/test/read-side-cli.test.ts b/test/read-side-cli.test.ts new file mode 100644 index 0000000..734b19e --- /dev/null +++ b/test/read-side-cli.test.ts @@ -0,0 +1,293 @@ +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, 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 read side, end to end, against a stub Coolify shaped like a box nobody +// declared: its project is `Incubator` (capital I), its environment is +// `production` (Coolify's default, not ours), and its application is called +// `incubator-stack` rather than the manifest's `core`. +// +// Every one of those three is a real name from the live box this work came out +// of, and each one used to fail differently and badly: +// +// project → refused (already fixed, #12) +// environment → refused, and the "obvious" fix was to rename OUR environment +// to match the box — letting a machine due for deletion name +// the new box's environment forever (#17) +// resource → NOT refused: reported every required secret as individually +// MISSING from a box that was serving production at the time, +// and invited --override to hand-carry all of them (#18) + +const LIVE_ENV = { + MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET", + ADMIN_EMAIL: "founder@real-company.com", + LEFTOVER_FROM_2019: "nobody-asked-for-this", +}; + +let recipient: string; + +beforeAll(() => { + const dir = mkdtempSync(join(tmpdir(), "cast-age-")); + const 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 }; +const stubs: Stub[] = []; + +// appName is the knob: `incubator-stack` is the hand-built box (the manifest's +// `core` does not exist on it); `core` is the box whose names happen to line up. +async function stubCoolify(appName: string): Promise { + 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" }); + // Named as someone typed it, not as the repo slug would derive it. + if (path === "/projects") return json([{ uuid: "p1", name: "Incubator" }]); + // Coolify's default environment name — ours is `staging`. + if (path === "/projects/p1/production") + return json({ applications: [{ name: appName, uuid: "a1" }] }); + if (path === "/applications/a1/envs") + return json( + Object.entries(LIVE_ENV).map(([key, real_value]) => ({ + key, + real_value, + value: "REDACTED", + })), + ); + res.writeHead(404); + res.end("{}"); + }); + await new Promise((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((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: + 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 +`; + +const TEMPLATE = `NODE_ENV=production +MAILGUN_API_KEY=\${MAILGUN_API_KEY} +ADMIN_EMAIL=\${ADMIN_EMAIL} +`; + +function fixture(url: 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"), + 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 run( + verb: string, + args: string[], + opts: { stdin?: string; env?: Record } = {}, +): Promise<{ code: number; output: string }> { + return new Promise((resolve) => { + const child = spawn("node", ["dist/cli.js", verb, ...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) => [ + "heavy-duty/incubator", + "--env", + "staging", + "--state", + f.state, + "--path", + f.checkout, +]; + +describe("--environment (the read-side coordinate, #17)", () => { + it("reads the box's environment name while the store keeps OURS", async () => { + const f = fixture((await stubCoolify("core")).url); + const r = await run( + "capture", + [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + "--override", + "ADMIN_EMAIL", + ], + { + stdin: "staging\n", + env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" }, + }, + ); + expect(r.code).toBe(0); + // THE POINT: the box calls it `production`; we call it `staging`. The store + // is keyed by OUR name. A hand-built box does not get to name our + // environments, our store, or our age key — it only gets to be read. + expect(existsSync(f.store)).toBe(true); + expect( + existsSync(join(f.state, "secrets", "incubator.production.env.age")), + ).toBe(false); + }); + + it("refuses an absent environment by naming --environment, not by inviting a rename", async () => { + const f = fixture((await stubCoolify("core")).url); + // No --environment: cast looks for `staging`, the box has `production`. + const r = await run("capture", [...base(f), "--project", "Incubator"]); + expect(r.code).toBe(2); + expect(r.output).toContain('has no environment "staging"'); + expect(r.output).toContain("Pass --environment "); + // The old message said "re-run with --env naming the environment as it + // exists here" — i.e. adopt the box's vocabulary as our own. That is the + // sentence that cost us a rename across three repos. + expect(r.output).not.toContain("Re-run with --env"); + expect(existsSync(f.store)).toBe(false); + }); +}); + +describe("absent resource (#18)", () => { + it("refuses on the RESOURCE, naming what exists, instead of reporting every secret missing", async () => { + const f = fixture((await stubCoolify("incubator-stack")).url); + const r = await run("capture", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + ]); + expect(r.code).toBe(2); + // The finding: the manifest and the box disagree about what this is called. + expect(r.output).toContain("core"); + expect(r.output).toContain("incubator-stack"); + expect(r.output).toContain("do not exist here"); + // NOT the old report — a wall of per-name MISSING against a box that HAS + // every one of those secrets, sitting right there under another name, with + // --override offered as the remedy (which would have written a perfectly + // valid store and buried the real problem forever). + // + // The assertion is that no INDIVIDUAL SECRET is named at all: this is a + // resource-level finding, and reporting it per-secret is what made the + // original failure so convincingly wrong. (Asserting on the word "MISSING" + // would only catch the prose that explains why we are not doing that.) + for (const name of Object.keys(LIVE_ENV)) { + expect(r.output).not.toContain(name); + } + expect(existsSync(f.store)).toBe(false); + }); +}); + +describe("cast inventory (#19)", () => { + it("shows both sides of a hand-built box — and no values", async () => { + const f = fixture((await stubCoolify("incubator-stack")).url); + const r = await run("inventory", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + ]); + expect(r.code).toBe(0); + // Declared, absent from the box. + expect(r.output).toContain("core"); + // On the box, undeclared — including a var the manifest never heard of. + expect(r.output).toContain("incubator-stack"); + expect(r.output).toContain("LEFTOVER_FROM_2019"); + // Keys, never values. This artifact is meant to be pasted into a PR. + for (const value of Object.values(LIVE_ENV)) { + expect(r.output).not.toContain(value); + } + }); + + it("needs no secret store, no age key, and no age_recipient to run", async () => { + // The whole point: inventory runs BEFORE adoption, when none of those exist. + const f = fixture((await stubCoolify("incubator-stack")).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 run("inventory", [ + ...base(f), + "--project", + "Incubator", + "--environment", + "production", + ]); + expect(r.code).toBe(0); + expect(r.output).toContain("inventory — heavy-duty/incubator staging"); + }); +});