Merge pull request #20 from claude-hdb/feat/inventory-and-read-side-coordinates

read-side coordinates (#17, #18) + cast inventory (#19)
This commit is contained in:
Daniel Marin 2026-07-13 19:22:30 +01:00 committed by GitHub
commit bc726db4c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 915 additions and 22 deletions

View file

@ -60,9 +60,10 @@ Pass it with `--state <dir>`, or set `CAST_STATE`. Defaults to the cwd.
## Commands
```sh
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast team [--env <env>]
@ -75,6 +76,12 @@ cast team [--env <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 <name>` | the project isn't named after the repo (`Incubator`, not `incubator`) |
| `--environment <name>` | 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

View file

@ -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>,
): 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<string, Site[]> {
const byRef = new Map<string, Site[]>();
for (const { ref, resource, key } of required) {

View file

@ -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 <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full] [--project <name>]
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--generated <NAME>] [--override <NAME>] [--force]
const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full] [--project <name>] [--environment <name>]
cast capture <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--generated <NAME>] [--override <NAME>] [--force]
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast team [--env <env>]
@ -63,6 +73,13 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--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 <name>
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 <NAME> 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<LiveLookup, { found: false }>,
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 <name> 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 <name> if this instance names it differently.",
].join("\n");
}
@ -439,6 +467,7 @@ async function main(): Promise<number> {
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<number> {
// 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<number> {
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<number> {
renderAbsentTarget(lookup, {
orgRepo,
overridden: values.project !== undefined,
envOverridden: values.environment !== undefined,
}),
);
return 2;
@ -530,7 +568,10 @@ async function main(): Promise<number> {
);
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<number> {
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<number> {
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<number> {
// 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<number> {
);
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),

View file

@ -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.<env>.forbidden_var_patterns` in the state repo. The rule is
// PRESENCE, not value: a forbidden var set to "false" still refuses the apply,

173
src/inventory.ts Normal file
View file

@ -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<string>) => [...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");
}

View file

@ -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,

124
test/inventory.test.ts Normal file
View file

@ -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"]);
});
});

293
test/read-side-cli.test.ts Normal file
View file

@ -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<void> };
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<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" });
// 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<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
const MANIFEST = `project: incubator
environments:
staging:
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<string, string> } = {},
): 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<typeof fixture>) => [
"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 <name>");
// 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");
});
});