cast/src/bindings.ts
claude-hdb 6849d29f0e feat(destroy): a scoped teardown verb, gated in state (#43)
`apply` fails closed on an immutable field with "resolve manually" — which
meant a hand deletion in the Coolify UI, unscoped and unconfirmed, against an
instance whose token can see every project on it. That is how the wrong project
gets deleted.

`cast destroy <org>/<repo> --env <env> [--with-project]` is that act, scoped:

- MANIFEST-SCOPED. It deletes the resources the manifest declares in that
  project and that environment, in reverse dependency order (applications →
  services → databases). Anything else it finds is reported and LEFT STANDING —
  that report is how a resource created outside cast gets discovered, and the
  boxes in this fleet are multi-project by design.
- Not a flag on apply. `apply never deletes` is the invariant that makes it safe
  to run on a schedule; apply.ts and diff.ts are untouched.
- REFUSES rather than no-ops: --all (always), a read-only instance, an absent
  project (D-237 — an absent target must never read as a clean empty plan), a
  manifest that declares nothing this environment holds, and --with-project
  while anything undeclared is still in the project.
- The prod interlock lives in STATE, not argv: environments.<env>.destroy_allowed
  in environments.yaml, absent = refuse. A flag is a thing you type without
  reading; this is a line a human edits, commits and merges.
- The plan says what the delete COSTS: every database line carries its backup
  schedule and when the last backup landed. A backups route cast cannot read
  prints UNKNOWN and is treated as unrecoverable — it never rounds down to NONE.
- Last gate: the environment's name, typed (capture's ceremony).

Coolify's DELETE query params are sent explicitly (all four default to true):
delete_volumes, delete_connected_networks, delete_configurations — and
docker_cleanup=FALSE, because that one prunes the whole SERVER, and these boxes
host other people's production.
2026-07-14 22:52:17 +00:00

474 lines
23 KiB
TypeScript

import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { z } from "zod";
// The team an environment's token MUST belong to. Give `id` (the true auth
// identity — names can be renamed or duplicated), `name` (human-readable,
// greppable), or both; both are checked when both are given. `cast team`
// prints the current token's id and name so this can be filled in.
const TeamSchema = z
.object({
// Non-negative, NOT positive: team **0 is the Root Team** — the one the
// first user of an instance gets (`if ($user->id === 0) { $team['id'] = 0;
// $team['name'] = 'Root Team'; }`, app/Models/User.php @ v4.1.2). On a
// single-admin Coolify that is the team everything lives in, so rejecting
// 0 would make the id check — the strong half of the assert — unusable on
// exactly the topology that most needs it.
id: z.number().int().nonnegative().optional(),
name: z.string().min(1).optional(),
})
.strict()
.refine((t) => t.id !== undefined || t.name !== undefined, {
message: "team must give at least one of `id` or `name`",
});
// State that belongs to ONE project inside ONE environment — not to the
// environment as a whole.
//
// The environment block above it is the wrong scope for any of this, and was
// always going to be: it says `server:`, and a server is exactly the thing two
// projects can share. Everything here is keyed the way `github_apps` is (see
// githubAppNameFor) — by the repo, full `<org>/<repo>` slug first — because the
// repo is what identifies a project to US. The Coolify project NAME is theirs,
// it is what someone typed into a UI, and `--project` exists precisely because
// it does not have to match.
const ProjectBindingSchema = z
.object({
// The Docker network this project's resources are created on — a raw UUID,
// read out of the Coolify UI, exactly like `s3_destination`.
//
// A UUID and not a name, deliberately, even though `server:` right above is
// a name: Coolify 4.1.2 has NO destinations API at all (zero routes in
// routes/api.php @ v4.1.2), so unlike a server name, cast cannot resolve a
// destination name to anything. A key called `destination` would invite one.
// See reference/README.md.
//
// Optional, and absent means "whatever the server's only destination is" —
// which is correct until the server has two, and is why this went unnoticed:
// Coolify picks `$destinations->first()` when a server has exactly one.
destination_uuid: z.string().optional(),
// The app `cast smoke` writes its canary env vars to. Project-scoped
// because it names one project's application: `core` is incubator's compose
// app, and the day a second project deploys into this environment, an
// environment-scoped (let alone the state-file-scoped one it replaces)
// `smoke_target: core` is simply wrong.
//
// It is declared here AND resolved here (#29): `smoke` looks the name up in
// this project, in this environment, and refuses when it is not there. A
// bare app name is unique nowhere else — the instance-wide lookup it used to
// do could pick prod's `core` while smoking staging.
smoke_target: z.string().optional(),
})
.strict();
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
// One project in the registry — the top-level `projects:` block, which is the
// list of what EXISTS. Nothing else in this file says that: `environments:`
// says where things are deployed to, `environments.<env>.projects.<repo>` says
// how one project is placed once you already know it is there, and
// `github_apps` says how to clone one you have already named. "Every project"
// was, until this block, a thing the operator remembered.
//
// Two things need the list, and neither can be built without it: fleet
// operations (`cast diff --all`, #26 — iterating "every project in this
// environment") and rebuild-from-state (#27 — a Coolify restored from the state
// repo, which cannot even be attempted without knowing what was on it).
const RegisteredProjectSchema = z
.object({
// OUR environment names — the values `--env` takes, the keys of the
// `environments:` block above — never Coolify's. The distinction is the same
// one `--env` vs `--environment` draws everywhere else in cast.
//
// Non-empty: a project registered into no environment is not a registration,
// it is a line of YAML that reads like one. It would be skipped by every
// fleet run silently.
environments: z.array(z.string().min(1)).nonempty(),
})
.strict();
export type RegisteredProject = z.infer<typeof RegisteredProjectSchema>;
const BindingsSchema = z
.object({
environments: z.record(
z
.object({
server: z.string(),
// REQUIRED, and deliberately so: this is what makes the team assert
// fail-closed (see team.ts). An environment with no declared team
// is an environment cast cannot verify it is pointed at — and an
// unverifiable target is exactly the silent-duplicate-into-the-
// wrong-team failure this binding exists to prevent. No team, no
// apply. Today one state dir holds one token (.coolify.env), so
// every environment in it normally names the same team; declaring
// it per environment keeps each one's expectation explicit and
// survives a future split into per-environment tokens.
team: TeamSchema,
// The named Coolify instance this environment lives on
// (<state>/.coolify/<name>.env). Optional: with no binding and no
// --instance, cast reads <state>/.coolify.env exactly as it always
// has. Binding it here is what lets `--env prod` select the right
// control plane with no flag and no file edit — the connection
// target stops being implicit in a file's current contents.
// An explicit --instance still wins, so a one-off read against a
// legacy box needs no change to this file either.
instance: z.string().optional(),
// The age recipient (public key) this environment's secret store is
// encrypted TO. Only `capture` needs it — decryption resolves an
// identity per keyFileFor, and the state repo deliberately holds
// ciphertext but never the identity that opens it. This is the
// public half, so it is safe to commit here next to the bindings.
age_recipient: z.string().optional(),
s3_destination: z.string().optional(),
// Var-name patterns this environment refuses outright (see
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
// whatever family of flags enables destructive tooling.
forbidden_var_patterns: z.array(z.string()).optional(),
// THE DESTROY INTERLOCK (#43). Absent means `cast destroy` REFUSES —
// and absent is the default, forever, on every environment nobody has
// deliberately opened.
//
// It is a binding and not a flag because a flag is not a gate. `--yes`
// is a thing you type without reading, and by the second week it is in
// the shell history above the command it was meant to guard. This is a
// line a human edits, commits, and merges — and the cutover checklist
// deletes it the moment the environment carries real data, after which
// destroying that environment costs a PR against the state repo. That is
// the correct amount of friction for a verb that ends companies.
//
// It lives HERE, in private state, next to forbidden_var_patterns, for
// exactly the reason that one does: a change on one side must not be
// able to lower its own guard. The manifest is a PR against the product
// repo; the permission to delete that product's production is not.
//
// Optional, and read ONLY by destroy (bindings written before it existed
// keep loading, and refuse — which is the right answer for a state file
// that has never heard of the verb).
destroy_allowed: z.boolean().optional(),
// Per-project state, keyed by repo. Optional: an environment whose
// server hosts one project needs none of it.
projects: z.record(ProjectBindingSchema).optional(),
})
.strict(),
),
// THE REGISTRY: which projects exist at all, keyed by the full `<org>/<repo>`
// slug. The key IS the repo — there is no `repo:` field inside, because a
// second place to write the same string is a second place for it to be
// wrong.
//
// Full slug REQUIRED, with no bare-`<repo>` fallback — the one place in this
// file where that fallback does not exist. `github_apps` and
// `environments.<env>.projects` carry one because they predate the lesson
// (#12) and there are state files in the wild keyed the old way; this block
// is new, has no such files, and so gets to be right from the start. A bare
// `<repo>` is unique only *within* an org, which is precisely why it is not
// a key.
//
// Optional: a state file written before the registry existed keeps loading
// untouched, and `projectsIn` answers `[]` for it.
projects: z.record(RegisteredProjectSchema).optional(),
// Keyed by the repo the App clones for. Prefer the FULL `<org>/<repo>`
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
// existing state files keep working.
github_apps: z.record(z.string()),
// GONE — nothing reads this any more (#29). It is still DECLARED here, and
// refused below with a message, precisely because it is gone: this schema is
// .strict(), so deleting the field outright would make an unmigrated state
// file fail with a raw zod "unrecognized key" — and loadBindings runs for
// EVERY verb, so `diff`, `apply`, `capture` and `inventory` would all die on
// a key none of them ever read, mid-migration, with a message about nothing.
// A key that has to be removed by hand gets a sentence saying how.
smoke_target: z.string().optional(),
})
.strict()
// Every check here defends one failure, from two ends: state that a command
// will silently fail to act on. A registry that lies makes a fleet run skip a
// project — and a skipped project prints exactly what a clean one prints,
// nothing. A removed key that is still present makes `smoke` look like it has
// a target when nothing reads it. Silence is the one report that must never be
// ambiguous, so these are parse-time errors (every verb loads bindings, so
// every verb refuses) rather than warnings some command might print.
.superRefine((bindings, ctx) => {
// GONE, not merely deprecated (#29) — see the field's note above. Reported
// first, and without returning: a file may well carry both this and a
// registry that needs fixing, and the operator should learn about both in
// one run rather than one per run.
if (bindings.smoke_target !== undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["smoke_target"],
// The key could never be fixed, only carried: `smoke` now resolves its
// target inside the project and environment the target was declared
// under, and a key scoped to the whole state file has no project to
// scope to. Carrying it meant keeping the instance-wide name lookup
// alive for exactly the invocation that most needed it dead —
// `cast smoke --env prod`.
message: [
"the top-level `smoke_target` key is no longer read (#29)",
"",
` found: smoke_target: ${bindings.smoke_target} (at the top level of this file)`,
"",
"It named ONE project's application from a key scoped to the whole state file,",
"so it could not tell two projects apart — or even prod's app from staging's.",
"`cast smoke` now resolves that name inside the project and environment it was",
"declared under, and this key names no project to resolve it in.",
"",
"Move it under the project it belongs to, and pass that repo to `cast smoke`:",
"",
" environments:",
" <env>:",
" projects:",
" <org>/<repo>:",
` smoke_target: ${bindings.smoke_target}`,
"",
" cast smoke <org>/<repo> --env <env>",
].join("\n"),
});
}
const registry = bindings.projects;
if (!registry) return;
const knownEnvs = Object.keys(bindings.environments);
const knownEnvList = knownEnvs.join(", ") || "(none)";
for (const [slug, project] of Object.entries(registry)) {
// A key with no `/` is not a repo. See the schema note above: no fallback.
if (!slug.includes("/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", slug],
message: [
`projects["${slug}"] is not a repo — a registry key has no meaning without its org`,
"",
` found: projects["${slug}"]`,
" wanted: a full <org>/<repo> slug",
"",
"A bare <repo> is unique only *within* an org: heavy-duty/incubator and",
"acme/incubator collapse onto one entry, and the registry then claims one",
"project where there are two. Unlike github_apps, this block is new and has",
"no legacy state files to support, so there is no bare-<repo> fallback.",
"",
" projects:",
` <org>/${slug}:`,
` environments: [${project.environments.join(", ")}]`,
].join("\n"),
});
}
// Every environment named here must be one that actually exists. A typo
// makes the project real but its environment imaginary — so `--all` visits
// nothing for it, reports nothing about it, and exits clean.
for (const envName of project.environments) {
if (!(envName in bindings.environments)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", slug, "environments"],
message: [
`projects["${slug}"] is registered in environment "${envName}", which does not exist`,
"",
` registered: projects["${slug}"].environments → ${project.environments.join(", ")}`,
` known envs: ${knownEnvList}`,
"",
"An environment nothing defines is one that no command can visit: a fleet",
"run would skip this project, and a silently skipped project reads exactly",
"like a clean one. Fix the name, or declare the environment:",
"",
" environments:",
` ${envName}:`,
" server: <server>",
" team: { id: <id>, name: <name> }",
].join("\n"),
});
}
}
}
// The other direction, and the one that rots quietly: a project carrying
// per-environment state (destination_uuid, smoke_target — #21) in an
// environment the registry does not register it for. That state is then real
// enough to be used by a direct `cast apply <repo> --env <env>` and invisible
// to every fleet run — the two blocks describing two different fleets.
//
// Only checked when `projects:` is present, so state files written before the
// registry keep loading exactly as they did.
for (const [envName, env] of Object.entries(bindings.environments)) {
for (const key of Object.keys(env.projects ?? {})) {
const entry = registry[key];
if (entry?.environments.includes(envName)) continue;
// The likeliest cause, worth saying out loud: a legacy bare-<repo> key
// (which projectBindingFor still resolves) under a registry that is
// correctly keyed by slug. The fix is a rename, not a registration.
const slugFor = key.includes("/")
? undefined
: Object.keys(registry).find((s) => s.endsWith(`/${key}`));
const cause = slugFor
? [
` registry has: projects["${slugFor}"]`,
"",
"The binding uses the legacy bare-<repo> key. The registry is keyed by the",
`full slug, so rename it to match — environments.${envName}.projects["${slugFor}"].`,
]
: entry
? [
` registered for: ${entry.environments.join(", ")}`,
"",
"Register the project in this environment, or drop the binding — state that",
"no fleet run will ever visit is state that stops being true without anyone",
"finding out:",
"",
" projects:",
` ${key}:`,
` environments: [${[...entry.environments, envName].join(", ")}]`,
]
: [
` registry has: ${Object.keys(registry).join(", ") || "(nothing)"}`,
"",
"The project is not in the registry at all, so no fleet run will ever visit",
"it — while this binding says it is deployed here. Register it, or drop the",
"binding:",
"",
" projects:",
` ${key}:`,
` environments: [${envName}]`,
];
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["environments", envName, "projects", key],
message: [
`environments.${envName}.projects["${key}"] is bound in an environment the registry does not register it for`,
"",
` bound at: environments.${envName}.projects["${key}"]`,
...cause,
].join("\n"),
});
}
}
});
export type Bindings = z.infer<typeof BindingsSchema>;
// Resolve the Coolify GitHub App name for a repo, full slug first.
//
// The short name alone is not a key: `<repo>` is only unique *within* an org,
// so `heavy-duty/incubator` and `acme/incubator` collapse onto one entry and
// whichever App is bound there gets used to clone BOTH — silently, because a
// wrong-but-existing App resolves to a real uuid and the create succeeds. The
// full slug is the thing that actually identifies a repo, so it wins.
//
// The bare-`<repo>` fallback is kept deliberately: it is what every state file
// written before this used, and dropping it would break them for no gain. A
// short key is unambiguous right up until a second org shows up, which is
// precisely when the full-slug key it falls back from starts winning instead.
export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
const name = bindings.github_apps[orgRepo] ?? bindings.github_apps[repoShort];
if (!name) {
throw new Error(
[
`no GitHub App bound for ${orgRepo}`,
"",
` looked for: github_apps["${orgRepo}"], then github_apps["${repoShort}"]`,
` bound repos: ${Object.keys(bindings.github_apps).join(", ") || "(none)"}`,
"",
"Add it to environments.yaml, keyed by the full slug:",
"",
" github_apps:",
` ${orgRepo}: <the App's name in Coolify>`,
].join("\n"),
);
}
return name;
}
// The project-scoped bindings for one repo in one environment, or undefined if
// the environment declares none. Same full-slug-then-bare-repo lookup as
// githubAppNameFor, for the same reason — see the note there.
//
// Absence is NOT an error: `projects:` is optional, and an environment with a
// single project on a single-destination server has nothing to say here. The
// callers that genuinely need a value (smoke) say so themselves.
export function projectBindingFor(
bindings: Bindings,
envName: string,
orgRepo: string,
): ProjectBinding | undefined {
const projects = bindings.environments[envName]?.projects;
if (!projects) return undefined;
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
return projects[orgRepo] ?? projects[repoShort];
}
// The app `cast smoke` targets, in ONE project of ONE environment — the only
// scope in which a bare application name is a coordinate at all. There is no
// fallback and deliberately none: a name that cannot say which project and
// which environment it belongs to does not identify an application, and `smoke`
// writes to whatever it identifies (#29).
//
// `orgRepo` is required for the same reason: it is the project. Absence is not
// an error here — an environment may simply declare no smoke target — but the
// caller has to say so itself, and `smoke` does.
export function smokeTargetFor(
bindings: Bindings,
envName: string,
orgRepo: string,
): string | undefined {
return projectBindingFor(bindings, envName, orgRepo)?.smoke_target;
}
// The `<org>/<repo>` slugs registered for one environment — the list a fleet
// operation iterates (`cast diff --all`, #26) and a rebuild reads (#27).
//
// SORTED, deliberately: the order of keys in a YAML file is an accident of who
// typed what when, and a fleet run's output — which a human reads top to bottom,
// and CI diffs — must not reshuffle because someone appended a project. The
// registry is a set; this returns it as one.
//
// `[]` when there is no registry, which is every state file written before this
// block existed. That is the honest answer to "which projects are registered
// here" when nothing is registered anywhere — and it is emphatically NOT a
// licence to do nothing with it: the fleet verbs REFUSE on an empty list (#26,
// renderEmptyRegistry). An earlier draft of this comment guessed that `[]` would
// make `--all` over an unmigrated state file "a clean no-op rather than a crash",
// which is precisely backwards — "0 projects, clean" is a clean fleet's report
// printed over a fleet nobody looked at. There is no honest no-op here; there is
// only a refusal that says what it looked for.
export function projectsIn(bindings: Bindings, envName: string): string[] {
const registry = bindings.projects;
if (!registry) return [];
return Object.entries(registry)
.filter(([, project]) => project.environments.includes(envName))
.map(([slug]) => slug)
.sort();
}
export function loadBindings(
path: string,
opts: { overrideText?: string } = {},
): Bindings {
const text = opts.overrideText ?? readFileSync(path, "utf8");
const result = BindingsSchema.safeParse(parse(text));
if (!result.success) {
// Zod's own `.message` is the entire issue array as JSON — which renders the
// refusals above as one long line of `\n` escapes, i.e. throws away the part
// of them that was worth writing. That matters most for the ones that are
// not typos at all but migrations (the removed `smoke_target`), where the
// message IS the instruction. Render the issues instead.
const detail = result.error.issues
.map((issue) => {
// A multi-line message is one WE wrote: it already names the path, the
// cause, and the YAML to write. A one-line message is zod's ("Required"),
// and is useless without the path it happened at.
if (issue.message.includes("\n")) return issue.message;
const where = issue.path.map(String).join(".");
return where ? `${where}: ${issue.message}` : issue.message;
})
.join("\n\n");
throw new Error(`invalid bindings ${path}:\n\n${detail}`);
}
return result.data;
}