cast/src/destroy.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

600 lines
26 KiB
TypeScript

import type { ResourceKind } from "./diff.js";
// REVERSE DEPENDENCY ORDER: applications, then services, then databases.
//
// The order a thing is torn down in is the order it was built in, backwards. A
// database removed while an application still points at it does not fail
// quietly — it fails as a restart loop against a hostname that stopped
// resolving, on a box that is still serving somebody else's project. Deleting
// the consumers first means every delete after the first one is a delete of
// something nothing is talking to any more.
//
// DEFINED HERE, not imported from apply.ts, and deliberately: apply owns the
// FORWARD create-order and PR #45 is settling what that order actually is. One
// shared array with a `.reverse()` at one of its two call sites is a constant
// whose meaning depends on which caller you read last — and the one that gets it
// backwards deletes a database first. Two constants, two comments, and a
// follow-up to unify them once #45 has landed and there is one place that can
// honestly own both directions (noted in the PR).
export const DESTROY_ORDER: readonly ResourceKind[] = [
"application",
"service",
"database",
] as const;
// What a database's backups look like from here, and the whole point of the
// three-way split: "backed up", "not backed up" and "cast could not tell" are
// three different answers, and only the first one makes a delete recoverable.
//
// `unknown` is not a degraded `none`. A database that IS backed up must never
// read at the confirmation prompt as one that is not, and a database that is NOT
// must never read as one that is — so anything cast cannot read confidently
// (an HTTP failure, a response shape it does not recognize) comes back as
// `unknown` carrying the reason, and the plan says out loud that an unknown
// database must be treated as unrecoverable.
export type BackupExecution = { at?: string; status?: string };
export type BackupSchedule = {
frequency?: string;
enabled?: boolean;
// The most recent execution, by `created_at`. Newest first is what the
// executions route returns, but the ordering is not something to depend on:
// this is computed.
last?: BackupExecution;
// How many executions the schedule has ever had. Zero is the case that looks
// like a backup and is not one.
executions: number;
};
export type BackupState =
| { state: "scheduled"; schedules: BackupSchedule[] }
| { state: "none" }
| { state: "unknown"; reason: string };
// Parse GET /databases/{uuid}/backups.
//
// The route returns `ScheduledDatabaseBackup::…->with('executions')->get()` —
// a bare JSON ARRAY of backup configurations, each carrying its executions
// eager-loaded (DatabasesController@database_backup_details_uuid,
// coollabsio/coolify v4.1.2). The vendored OpenAPI documents the response as the
// literal string "Content is very complex. Will be implemented later.", which is
// why this parses the SOURCE's shape and then refuses to guess: an envelope it
// does not recognize is `unknown`, never `none`.
//
// PR #51 is settling this route's real response against a live instance in
// parallel. If it lands a different envelope, this function is the one place
// that has to learn about it — and until it does, an unrecognized shape degrades
// to `unknown` rather than to a lie.
export function readBackupState(raw: unknown): BackupState {
const configs = Array.isArray(raw)
? raw
: // Two envelopes seen in the wild around Laravel APIs — accepted because
// reading them is free, and misreading them costs a database.
Array.isArray((raw as { data?: unknown })?.data)
? ((raw as { data: unknown[] }).data as unknown[])
: Array.isArray((raw as { backups?: unknown })?.backups)
? ((raw as { backups: unknown[] }).backups as unknown[])
: undefined;
if (!configs) {
return {
state: "unknown",
reason: `GET /databases/{uuid}/backups returned a shape cast does not recognize (${typeof raw})`,
};
}
if (configs.length === 0) return { state: "none" };
const schedules = configs.map((c): BackupSchedule => {
const config = (c ?? {}) as Record<string, unknown>;
const executions = Array.isArray(config.executions)
? (config.executions as Array<Record<string, unknown>>)
: [];
// Newest by created_at. An entry whose timestamp will not parse is not
// dropped from the count — it is simply never the newest, because a date
// cast cannot read is not evidence that a backup landed.
let last: BackupExecution | undefined;
let lastMs = Number.NEGATIVE_INFINITY;
for (const e of executions) {
const at = typeof e.created_at === "string" ? e.created_at : undefined;
const ms = at ? Date.parse(at) : Number.NaN;
if (!Number.isNaN(ms) && ms > lastMs) {
lastMs = ms;
last = {
at,
status: typeof e.status === "string" ? e.status : undefined,
};
}
}
return {
frequency:
typeof config.frequency === "string" ? config.frequency : undefined,
enabled: typeof config.enabled === "boolean" ? config.enabled : undefined,
last,
executions: executions.length,
};
});
return { state: "scheduled", schedules };
}
// One resource this run will delete: what it is, what it is called, and — for a
// database — what deleting it costs.
export type DestroyTarget = {
kind: ResourceKind;
name: string;
uuid: string;
backup?: BackupState;
};
export type Resource = { kind: ResourceKind; name: string };
export type DestroyPlan = {
// Declared by the manifest AND present on the box, in DESTROY_ORDER.
targets: DestroyTarget[];
// Declared by the manifest and NOT on the box. Nothing to delete — reported
// because a manifest that names a resource this environment has never held is
// a finding in its own right (a typo, a stale block, an environment that was
// never applied), and because it is how "I deleted three of four" stops
// reading like "I deleted everything".
absent: Resource[];
// On the box and NOT declared by the manifest. LEFT STANDING, always, and
// reported loudly: this is the one report that tells an operator something was
// created outside cast. Deleting it would make destroy an environment wipe,
// which is precisely the verb this one refuses to be.
undeclared: Resource[];
};
export function planDestroy(
declared: Resource[],
live: Array<{ kind: ResourceKind; name: string; uuid: string }>,
): DestroyPlan {
const isSame = (a: Resource, b: Resource) =>
a.kind === b.kind && a.name === b.name;
const targets = live
.filter((l) => declared.some((d) => isSame(d, l)))
.map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid }))
.sort(
(a, b) =>
DESTROY_ORDER.indexOf(a.kind) - DESTROY_ORDER.indexOf(b.kind) ||
a.name.localeCompare(b.name),
);
const absent = declared
.filter((d) => !live.some((l) => isSame(d, l)))
.map((d) => ({ kind: d.kind, name: d.name }));
const undeclared = live
.filter((l) => !declared.some((d) => isSame(d, l)))
.map((l) => ({ kind: l.kind, name: l.name }));
return { targets, absent, undeclared };
}
// What a Coolify DELETE actually does, said once, where the plan is printed —
// because the operator at the prompt is deciding on the strength of it.
//
// DELETE /applications|databases|services/{uuid} takes four query parameters,
// and every one of them DEFAULTS TO TRUE
// ({Applications,Databases,Services}Controller@delete_by_uuid, v4.1.2):
// delete_volumes, delete_connected_networks, delete_configurations,
// docker_cleanup. The controller dispatches DeleteResourceJob with them, so a
// naked DELETE takes the volumes with it. cast sends them EXPLICITLY (see
// coolify.ts DELETE_RESOURCE_QUERY) rather than inheriting defaults it does not
// control.
export const DESTROY_PREAMBLE = [
"Coolify's delete takes the resource's VOLUMES with it (delete_volumes defaults to",
"true, and cast sends it explicitly): a database's data is gone with the database.",
"The delete is also ASYNCHRONOUS — the API queues a DeleteResourceJob and answers",
"immediately — so this plan is the last thing that happens before it is irreversible.",
];
const kindWidth = (rs: Array<{ kind: string }>) =>
Math.max(0, ...rs.map((r) => r.kind.length));
// A database line, in the terms that decide the answer at the prompt: not a
// UUID, but whether the thing that is about to be deleted can be brought back.
export function renderBackupLine(backup: BackupState | undefined): string[] {
if (!backup) return [];
if (backup.state === "unknown") {
return [
` backup schedule: UNKNOWN — cast could not read it (${backup.reason})`,
" treat this database as UNRECOVERABLE: an unread backup is not a backup.",
];
}
if (backup.state === "none") {
return [
" backup schedule: NONE — nothing has ever been scheduled for this database.",
" its volume goes with it, and cast cannot bring it back. UNRECOVERABLE.",
];
}
return backup.schedules.flatMap((s) => {
const freq = s.frequency ?? "(frequency unreadable)";
const enabled = s.enabled === false ? " [DISABLED]" : "";
const last =
s.executions === 0
? " last backup: NEVER — the schedule exists and has never run. UNRECOVERABLE."
: s.last?.at
? ` last backup: ${s.last.at}${s.last.status ? ` (${s.last.status})` : ""}`
: ` last backup: ${s.executions} execution(s), none with a readable timestamp — treat as UNRECOVERABLE`;
return [` backup schedule: ${freq}${enabled}`, last];
});
}
export function renderDestroyPlan(
plan: DestroyPlan,
ctx: {
orgRepo: string;
env: string;
project: string;
environment: string;
withProject: boolean;
},
): string {
const width = kindWidth(plan.targets);
const lines = [
"",
`destroy plan — ${ctx.orgRepo} ${ctx.env}`,
"",
` project: ${ctx.project} (on Coolify)`,
` environment: ${ctx.environment}`,
` scope: the ${plan.targets.length} resource(s) the manifest declares for ${ctx.env}, and nothing else`,
"",
"DELETE, in reverse dependency order (applications → services → databases):",
"",
];
if (plan.targets.length === 0) {
lines.push(" (none — the manifest declares nothing that exists here)");
}
for (const t of plan.targets) {
lines.push(` ${t.kind.padEnd(width)} ${t.name} ${t.uuid}`);
lines.push(...renderBackupLine(t.backup));
}
if (plan.absent.length > 0) {
lines.push(
"",
"declared by the manifest, ABSENT here (nothing to delete):",
...plan.absent.map(
(r) => ` ${r.kind.padEnd(kindWidth(plan.absent))} ${r.name}`,
),
);
}
lines.push(
"",
"LEFT STANDING — on this box, and NOT declared by the manifest:",
"",
);
if (plan.undeclared.length === 0) {
lines.push(
" (nothing — every resource here is one the manifest declares)",
);
} else {
lines.push(
...plan.undeclared.map(
(r) => ` ${r.kind.padEnd(kindWidth(plan.undeclared))} ${r.name}`,
),
"",
"cast will NOT delete these, and this is not a limitation to work around: destroy is",
"manifest-scoped, and a resource here that the manifest does not declare was created",
"outside cast. That is a finding — write it down, or declare it — never a thing to",
"clean up on the way past.",
);
}
if (ctx.withProject) {
lines.push(
"",
`then --with-project: environment "${ctx.environment}", then project "${ctx.project}".`,
"Both only if EMPTY — Coolify refuses either with a 400 while anything is still in it,",
"and so does cast, before it asks.",
);
}
lines.push("", ...DESTROY_PREAMBLE);
return lines.join("\n");
}
// --all is REFUSED, always, and this is the only verb in cast that says so about
// a flag that exists.
//
// `apply --all` and `diff --all` iterate the registry because a fleet-wide read
// is what nobody does reliably by hand, and a fleet-wide apply is idempotent.
// Neither argument survives being pointed at a delete: there is no incident a
// fleet-wide destroy prevents, no operator who needs two projects gone so badly
// they cannot type the second one, and no way to un-run it. The flag is parsed
// (rather than left to explode as an unknown option) precisely so that this can
// be the answer.
export function renderDestroyAllRefusal(): string {
return [
"refusing to destroy: --all is not a thing destroy does — ever",
"",
" --all means: every project the registry lists for this environment.",
" destroy means: delete the resources one manifest declares.",
"",
"The two compose into a fleet-wide deletion, which is a thing that has no honest use",
"and exactly one outcome when it is wrong. `apply --all` is safe to iterate because",
"it is idempotent and never deletes; `diff --all` because it only reads. A destroy is",
"neither, and no amount of confirmation ceremony makes a loop over other people's",
"projects a reasonable thing to offer.",
"",
"Name ONE project:",
"",
" cast destroy <org>/<repo> --env <env>",
].join("\n");
}
// The interlock, and why it is not a flag.
//
// `--yes` is not a gate. It is a thing you type without reading, and by the
// second week it is in the shell history above the command it guards. The gate
// this verb needs has to be somewhere a human commits to it: `destroy_allowed:
// true` in environments.yaml, in the private state repo — edited, committed,
// reviewed, merged. Absent means destroy refuses.
//
// It lives in state and NOT in the product's manifest for the same reason
// `forbidden_var_patterns` does: a change on one side must not be able to lower
// its own guard. A manifest is a PR against the product repo; the guard on
// deleting that product's production must not be.
export function renderNoInterlock(
envName: string,
bindingsPath: string,
declared: boolean | undefined,
): string {
return [
`refusing to destroy: environment "${envName}" does not allow it`,
"",
` looked for: environments.${envName}.destroy_allowed: true`,
` in: ${bindingsPath}`,
` found: ${declared === undefined ? "(absent)" : `destroy_allowed: ${declared}`}`,
"",
"The gate on the one verb that deletes lives in STATE, not in argv. A flag is a thing",
"you type without reading; this is a line somebody has to edit, commit and merge. It",
"is `true` on an environment that is empty and being battle-tested, and it is DELETED",
"at cutover — from the moment an environment carries real data, destroying it takes a",
"PR against the state repo, which is the correct amount of friction for a verb that",
"ends companies.",
"",
" environments:",
` ${envName}:`,
" destroy_allowed: true # absent = destroy refuses. Removed at cutover, forever.",
].join("\n");
}
// The absent target, destroy's own copy of the D-237 refusal.
//
// Structurally the same lookup `diff`/`capture`/`smoke` refuse on
// (cli.ts renderAbsentTarget), with one difference that is the whole reason this
// exists rather than reusing it: that message ends by offering `--project` /
// `--environment`, and DESTROY HAS NEITHER, on purpose. Those two coordinates
// exist to point cast at a project or an environment that somebody else named,
// by hand, in a UI — which is precisely the thing this verb must never be
// pointed at. So the remedy it offers is a different one, and an absent project
// still refuses rather than reading back as an empty plan (which, for a verb
// whose plan is "delete nothing", would render as a perfectly clean teardown of
// an environment that is still standing).
export type AbsentTarget =
| { missing: "project"; project: string; available: string[] }
| { missing: "environment"; project: string; environment: string };
export function renderAbsentDestroyTarget(
lookup: AbsentTarget,
ctx: { orgRepo: string; env: string },
): string {
const head =
lookup.missing === "project"
? [
`refusing to destroy: no project named "${lookup.project}" exists in this team`,
"",
` looked for: project "${lookup.project}" (derived from the repo slug ${ctx.orgRepo})`,
` exists here: ${lookup.available.join(", ") || "(no projects at all)"}`,
]
: [
`refusing to destroy: project "${lookup.project}" has no environment "${lookup.environment}"`,
"",
` looked for: environment "${lookup.environment}" in project "${lookup.project}" (from --env ${ctx.env})`,
" note: an environment 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,
"",
"An absent target reads back exactly like an empty one, and an empty one gives this",
'verb a plan that says "delete nothing" — a clean-looking teardown of an environment',
"that is still standing, somewhere else, under a name cast was not told about.",
"",
"cast destroy takes no --project and no --environment, deliberately. Those coordinates",
"exist to point cast at a project or an environment SOMEBODY ELSE named in a UI, and",
"that is exactly the thing a delete must never be aimed at by a typo. destroy only ever",
"removes what cast's own manifest declares, in the project named after the repo, in the",
"environment named by --env.",
"",
"`cast inventory --env <env>` sweeps the instance and shows what is actually there.",
"Nothing was deleted.",
].join("\n");
}
// Nothing the manifest declares is here — and that is a REFUSAL, not a clean
// plan (D-237, and doubly so for a verb whose plan is "delete nothing").
//
// The project and the environment both exist; they simply hold none of the
// resources this manifest names. Reported as "0 to delete, done" that reads
// exactly like a successful teardown of an environment that is, in fact, still
// standing — under names cast was never told about. So it names what IS there.
export function renderNothingDeclaredHere(
plan: DestroyPlan,
ctx: { orgRepo: string; env: string; project: string; environment: string },
): string {
const width = kindWidth(plan.undeclared);
return [
`refusing to destroy: none of the ${plan.absent.length} resource(s) the manifest declares exists here`,
"",
` looked in: project "${ctx.project}", environment "${ctx.environment}"`,
` declared: ${plan.absent.map((r) => `${r.kind} ${r.name}`).join(", ")}`,
" exists here:",
...(plan.undeclared.length > 0
? plan.undeclared.map((r) => ` ${r.kind.padEnd(width)} ${r.name}`)
: [" (nothing at all)"]),
"",
"A destroy with nothing to destroy is not a clean run — it is a run that agreed with",
"you about the wrong box, or the wrong environment, or a manifest whose names this",
"environment has never used. Reported as a no-op it reads exactly like a successful",
"teardown, which is the one thing it must never read as.",
"",
"`cast inventory` shows both sides. Nothing was deleted.",
].join("\n");
}
// --with-project, blocked — by resources cast is not allowed to delete.
//
// Coolify refuses both deletes itself while anything is still inside
// (`Project has resources, so it cannot be deleted.` / `Environment has
// resources, so it cannot be deleted.` — ProjectController@delete_project /
// @delete_environment, v4.1.2, both 400). cast refuses FIRST, and before the
// confirmation prompt, because the alternative is an operator who typed the
// environment name, watched their resources get deleted, and then read a 400
// about a project that was never going to go away — having discovered only at
// that point that something else lives in it.
export function renderProjectNotEmptiable(
ctx: {
project: string;
environment: string;
},
blockers: {
undeclared: Resource[];
otherEnvironments: Array<{ name: string }>;
},
): string {
const lines = [
`refusing to destroy --with-project: project "${ctx.project}" would not be empty`,
"",
];
if (blockers.undeclared.length > 0) {
const width = kindWidth(blockers.undeclared);
lines.push(
` in environment "${ctx.environment}", not declared by this manifest:`,
...blockers.undeclared.map(
(r) => ` ${r.kind.padEnd(width)} ${r.name}`,
),
);
}
if (blockers.otherEnvironments.length > 0) {
lines.push(
` in other environments of project "${ctx.project}":`,
...blockers.otherEnvironments.map((e) => ` ${e.name} (not empty)`),
);
}
lines.push(
"",
"destroy deletes what the manifest declares. Everything above is something else —",
"another environment of this project, or a resource somebody created outside cast —",
"and cast will not delete either to make room for a project delete. Coolify would",
"refuse the delete too (400: `Project has resources, so it cannot be deleted.`), but",
"it would refuse it AFTER your resources were already gone.",
"",
"Nothing has been deleted. Re-run without --with-project to remove the declared",
"resources and leave the project standing, or deal with what is listed above first.",
);
return lines.join("\n");
}
// The four teardown calls destroy makes, behind an interface, for the same
// reason apply.ts has an Executor: the ORDER and the REFUSALS are the product,
// and they are tested against a fake rather than against a Coolify nobody has
// credentials for.
export type DestroyExecutor = {
deleteResource(target: DestroyTarget): Promise<void>;
// Of the project + environment this run is scoped to. Asked of Coolify, never
// inferred from "I just deleted everything": the deletes are queued jobs, and
// this is the only thing that knows whether they have run.
environmentIsEmpty(): Promise<boolean>;
deleteEnvironment(): Promise<void>;
deleteProject(): Promise<void>;
};
export type DestroyOutcome = {
deleted: DestroyTarget[];
environmentDeleted: boolean;
projectDeleted: boolean;
// Set when --with-project was asked for and did not (or could not) complete.
// Never a throw: the resources ARE deleted by then, and a stack trace over the
// top of that is not a report.
note?: string;
};
export type DestroyWait = {
attempts: number;
intervalMs: number;
sleep: (ms: number) => Promise<void>;
};
const DEFAULT_WAIT: DestroyWait = {
attempts: 20,
intervalMs: 1_000,
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
};
// Delete, in order, and then — only if asked, and only once Coolify agrees the
// environment is actually empty — take the environment and the project with it.
//
// The wait is not politeness, it is correctness: DELETE on a resource dispatches
// DeleteResourceJob onto a queue and returns "deletion request queued"
// immediately (v4.1.2). A DELETE /projects fired straight after would race the
// queue and 400 with "Project has resources" — an error message about a
// condition that stopped being true a second later, which is the kind of thing
// operators learn to re-run blindly. So cast asks, waits, and says so if the
// answer never comes.
export async function executeDestroy(
plan: DestroyPlan,
exec: DestroyExecutor,
opts: { withProject: boolean; wait?: Partial<DestroyWait> } = {
withProject: false,
},
): Promise<DestroyOutcome> {
const wait = { ...DEFAULT_WAIT, ...opts.wait };
const deleted: DestroyTarget[] = [];
for (const target of plan.targets) {
// No try/catch, deliberately: a delete that fails stops the run. The next
// one in the order would be a delete of something the failed one may still
// depend on, and "carry on and see" is not a disposition a teardown gets.
// The caller reports what WAS deleted (`deleted` is what it read), and the
// run is re-runnable — destroy is idempotent by construction, because a
// resource that is already gone is simply not in the next plan.
await exec.deleteResource(target);
deleted.push(target);
}
if (!opts.withProject) {
return { deleted, environmentDeleted: false, projectDeleted: false };
}
let empty = await exec.environmentIsEmpty();
for (let i = 0; i < wait.attempts && !empty; i++) {
await wait.sleep(wait.intervalMs);
empty = await exec.environmentIsEmpty();
}
if (!empty) {
return {
deleted,
environmentDeleted: false,
projectDeleted: false,
note: [
`--with-project: the ${deleted.length} resource(s) above were deleted, and the environment is`,
`still not empty after ${(wait.attempts * wait.intervalMs) / 1000}s. Coolify deletes on a queue, so this is either a slow`,
"queue or a resource that did not go. cast will not delete a project it cannot see is",
"empty. Nothing else was touched — re-run `cast destroy … --with-project` once the",
"environment is clear, and it will pick up from here.",
].join("\n"),
};
}
await exec.deleteEnvironment();
await exec.deleteProject();
return { deleted, environmentDeleted: true, projectDeleted: true };
}
export function renderDestroyResult(
outcome: DestroyOutcome,
ctx: { project: string; environment: string },
): string {
const lines = [""];
lines.push(
outcome.deleted.length === 0
? "deleted nothing"
: `deleted (queued with Coolify): ${outcome.deleted.map((t) => `${t.kind} ${t.name}`).join(", ")}`,
);
if (outcome.environmentDeleted) {
lines.push(`deleted environment "${ctx.environment}" (empty)`);
}
if (outcome.projectDeleted) {
lines.push(`deleted project "${ctx.project}" (empty)`);
}
if (outcome.note) lines.push("", outcome.note);
return lines.join("\n");
}