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.
This commit is contained in:
claude-hdb 2026-07-14 22:33:46 +00:00
parent d5d984f631
commit 6849d29f0e
7 changed files with 1889 additions and 5 deletions

View file

@ -71,6 +71,7 @@ cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast capture <org>/<repo> --env <env> --generated-only [--from <NAME>=<db>] cast capture <org>/<repo> --env <env> --generated-only [--from <NAME>=<db>]
cast inventory <org>/<repo> --env <env> cast inventory <org>/<repo> --env <env>
cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]] cast inventory --env <env> [--emit-draft <dir> [--recipient age1…] [--no-secrets]]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
@ -109,6 +110,15 @@ cast team [--env <env>]
bootstrap**: run *after* `apply`, it fills the store's provider-generated names bootstrap**: run *after* `apply`, it fills the store's provider-generated names
(a Coolify-made `DATABASE_URL`) with the values Coolify generated. See *The (a Coolify-made `DATABASE_URL`) with the values Coolify generated. See *The
bootstrap is two-pass* below. bootstrap is two-pass* below.
- **`destroy`** — the **only** verb that deletes what a manifest declared, and the
reason `apply` never has to. **Manifest-scoped**: it removes the resources this
manifest declares in this project and this environment, in reverse dependency
order (applications → services → databases), and **reports everything else it
finds without touching it**. It refuses `--all`, refuses a read-only instance,
refuses an absent project, and refuses any environment whose `environments.yaml`
binding does not carry `destroy_allowed: true`. The last gate is typing the
environment's name at a plan that says, for every database, whether it is backed
up and when the last backup landed. See *Tearing an environment down* below.
- **`server add`** — uploads a server's private key and registers it with Coolify. - **`server add`** — uploads a server's private key and registers it with Coolify.
- **`smoke`** — contract test against the project's `smoke_target`: proves - **`smoke`** — contract test against the project's `smoke_target`: proves
Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after Coolify's bulk env endpoint still *upserts* rather than replacing. Run it after
@ -811,6 +821,94 @@ later in the Coolify UI without touching a manifest, so "off" has to mean absent
This guard lives in your private state deliberately — not in the product's This guard lives in your private state deliberately — not in the product's
manifest. A product-side change must not be able to lower its own guard. manifest. A product-side change must not be able to lower its own guard.
## Tearing an environment down: `cast destroy`
```sh
cast destroy heavy-duty/incubator --env staging [--with-project]
```
`apply` fails closed on an immutable field (`build_pack`, `type`, `version`,
placement) with *"resolve manually"* — which used to mean a hand deletion in the
Coolify UI, against an instance whose token can see every project on it. That is
how you delete the wrong project. `destroy` is that act, scoped and gated.
**What it deletes:** the resources **the manifest declares**, in this project and
this environment, in reverse dependency order — applications, then services, then
databases. Nothing else. A resource it finds that the manifest does **not**
declare is **reported and left standing**, and that report is also how you find
out something was created outside cast. It is not an instance wipe and not an
environment wipe: the boxes in this fleet are multi-project by design (one of them
hosts two third-party client sites), and a delete you can point at a whole box is
one wrong argument away from somebody else's production.
**What it refuses:**
| refusal | why |
|---|---|
| `--all` | the one verb that must never iterate a fleet. `apply --all` is safe to loop because it is idempotent and never deletes; a loop over a delete has no honest use. |
| a read-only instance | the same `COOLIFY_READ_ONLY` assert `apply`/`smoke`/`server add` take. |
| an absent project | an absent target reads back exactly like an empty one — and an empty one gives *this* verb a plan that deletes nothing, which renders as a perfectly clean teardown of an environment that is still standing. It names what *is* there instead. |
| an environment without `destroy_allowed: true` | below. |
| anything but the environment's name, typed | the same ceremony `capture` uses. |
| `--with-project`, when anything cast did not declare is still in the project | Coolify refuses that delete too (`400 Project has resources`) — but it refuses it *after* your resources are gone. |
There is no `--project`, no `--environment` and no `--resource`. Those coordinates
exist to point cast at names **somebody else** chose in a UI, and that is exactly
the box a delete must never be aimed at.
**The interlock lives in state, not in argv:**
```yaml
environments:
staging:
server: staging-box
team: { id: 0, name: Root Team }
destroy_allowed: true # absent = destroy refuses. Removed at cutover, forever.
```
A `--yes` flag 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. This is a line
a human edits, commits and merges — in the **private state repo**, for the same
reason `forbidden_var_patterns` lives there: *a change on one side must not be able
to lower its own guard.* It is `true` while an environment is empty and being
battle-tested. **The cutover checklist deletes it the moment that environment
carries real data**, and from then on destroying it costs a PR.
**The plan says what the delete costs.** Coolify's delete takes the resource's
volumes with it, so a database line carries its backup schedule and when the last
backup actually landed — the difference between *recreate this* and *this is
gone*. A backup configuration cast cannot read prints `backup schedule: UNKNOWN`
and is treated as unrecoverable; it never rounds down to "none".
```
destroy plan — heavy-duty/incubator staging
project: incubator (on Coolify)
environment: staging
scope: 3 resource(s) the manifest declares for staging, and nothing else
DELETE, in reverse dependency order (applications → services → databases):
application core a1
database cache d2
backup schedule: NONE — nothing has ever been scheduled for this database.
its volume goes with it, and cast cannot bring it back. UNRECOVERABLE.
database postgres d1
backup schedule: 0 2 * * *
last backup: 2026-07-13T02:00:11Z (success)
LEFT STANDING — on this box, and NOT declared by the manifest:
service metabase
type the environment name to DESTROY the resources above (staging):
```
`--with-project` additionally removes the **environment** and then the **project**
both only if they are empty, and only after Coolify's delete queue has actually
drained (its DELETE returns *"deletion request queued"*, not *"deleted"*). It is
the way back to zero from a half-applied first run.
## Scripts ## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with Operational helpers, all argument-driven (`scripts/`): register a GitHub App with

View file

@ -731,6 +731,94 @@ application says which App cloned it, so cast binds every repo to the instance's
only GitHub App when there is exactly one (there is no other it could be), and only GitHub App when there is exactly one (there is no other it could be), and
writes a `REVIEW-…` marker when there is not. writes a `REVIEW-…` marker when there is not.
## Teardown (`cast destroy`)
`apply` never deletes. `destroy` is the one verb that does, and everything below
is what stands between it and the hand deletion in the Coolify UI it replaces.
### What a Coolify DELETE actually removes
`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 — each reads
`$request->boolean('delete_volumes', true)` and hands the four to
`DeleteResourceJob`). cast sends all four **explicitly**: a default is a thing the
vendor gets to change, and three of these decide whether an operator's data still
exists afterwards.
| parameter | cast sends | what it does (`app/Jobs/DeleteResourceJob.php`, v4.1.2) |
|---|---|---|
| `delete_volumes` | `true` | `Application::deleteVolumes``docker volume rm -f <storage>` per persistent storage (`docker compose down -v` for a compose app), then deletes the persistent-storage rows. **This is what makes a database delete unrecoverable** — its data volume goes with it. |
| `delete_connected_networks` | `true` | `docker network disconnect <uuid> coolify-proxy` and `docker network rm <uuid>` (`Application::deleteConnectedNetworks`). The network is named for the **resource's own uuid** — it is *not* the shared destination network the rest of the box hangs off, so a multi-project server keeps its network and the other projects on it keep running. Left `false`, every delete would leak a dead network. |
| `delete_configurations` | `true` | removes the resource's configuration directory on the server. |
| `docker_cleanup` | **`false`** | It is not scoped to the resource at all: it dispatches `CleanupDocker` against the **server**`docker container prune`, an image prune, `docker builder prune -af` (`app/Actions/Server/CleanupDocker.php`). The boxes in this fleet are multi-project by design and one of them hosts third-party production. A teardown of *our* project does not get to prune somebody else's build cache. Coolify runs its own scheduled cleanup. |
Independently of all four, the job also deletes the resource's **env vars**, file
storages, and — for a database — its SSL certificates and its **scheduled-backup
configurations** (`scheduledBackups()->delete()`). Backups already written to S3
are not touched by any of this; local backup files live under the storage the
delete removes.
**The delete is asynchronous.** The controller dispatches `DeleteResourceJob` onto
the `high` queue and answers `200 {"message": "…deletion request queued."}`. A 2xx
means *Coolify accepted the deletion*, not *the resource is gone* — which is why
`--with-project` polls `GET /projects/{uuid}/{env}` until the environment actually
reads back empty before it removes anything else, rather than racing the queue into
a `400`.
### Scope, order, and what is left standing
destroy deletes **the resources the manifest declares**, in this project and this
environment, in **reverse dependency order** (applications → services → databases —
`DESTROY_ORDER` in `src/destroy.ts`; a database removed while an app still points at
it does not fail quietly, it fails as a restart loop). Anything else it finds is
**reported and left standing**: that report is how a resource created outside cast
gets discovered, and deleting it would make this an environment wipe.
It takes **no `--project`, no `--environment`, no `--resource`**. Those coordinates
exist to point cast at names somebody else chose in a UI — which is exactly the box
a delete must never be aimed at.
### The gates
- **`--all` is refused, always.** `apply --all` is safe to iterate because it is
idempotent and never deletes; `diff --all` because it only reads. A loop over a
delete is neither.
- **A read-only instance is refused** (`assertWritable`), like `apply`/`smoke`/
`server add`.
- **An absent project is refused** and names what *is* there — the D-237 family, and
doubly so here: an absent target reads back exactly like an empty one, and an empty
one gives this verb a plan that deletes nothing, which renders as a clean teardown
of an environment that is still standing. The same refusal fires when the manifest
declares nothing this environment actually holds.
- **`environments.<env>.destroy_allowed: true` is required, and absent means refuse.**
A `--yes` flag is not a gate; it is a thing you type without reading. The gate lives
in the private state repo — a line a human edits, commits and merges — for the same
reason `forbidden_var_patterns` does: *a change on one side must not be able to lower
its own guard.* It is `true` on an environment that is empty and being battle-tested,
and the cutover checklist **deletes it** the moment that environment carries real data.
- **The plan, then the environment's name, typed** — the ceremony `capture` uses. Names
and kinds, never values.
- **`--with-project` is refused up front** when anything cast did not declare is still
in the environment, or when another environment of the project holds resources.
Coolify refuses those deletes too (`400 Project has resources, so it cannot be
deleted.` / `400 Environment has resources…``ProjectController@delete_project` /
`@delete_environment`, both guarded by `isEmpty()`), but it refuses them *after* the
declared resources are already gone.
### What a database line says
`GET /databases/{uuid}/backups` returns the backup configurations with their
executions eager-loaded (`ScheduledDatabaseBackup::…->with('executions')->get()` —
`DatabasesController@database_backup_details_uuid`), so one call answers both halves
of the only question that matters at the prompt: *is this database backed up, and did
a backup ever actually land?* The vendored OpenAPI documents that response as the
literal string *"Content is very complex. Will be implemented later."*, so cast parses
the source's shape and **refuses to guess**: an envelope it does not recognize, or a
route that errors, prints `backup schedule: UNKNOWN` with the reason and is treated as
unrecoverable. It never rounds down to `NONE` — a database that *is* backed up must
never read as one that is not, and the reverse must never happen either.
## Cloning a private manifest ## Cloning a private manifest
`resolveCheckout` resolves git credentials **inside cast**, in a fixed order — `resolveCheckout` resolves git credentials **inside cast**, in a fixed order —

View file

@ -125,6 +125,27 @@ const BindingsSchema = z
// assertEnvVarPolicy). Operator-owned guard: prod typically bans // assertEnvVarPolicy). Operator-owned guard: prod typically bans
// whatever family of flags enables destructive tooling. // whatever family of flags enables destructive tooling.
forbidden_var_patterns: z.array(z.string()).optional(), 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 // Per-project state, keyed by repo. Optional: an environment whose
// server hosts one project needs none of it. // server hosts one project needs none of it.
projects: z.record(ProjectBindingSchema).optional(), projects: z.record(ProjectBindingSchema).optional(),

View file

@ -34,6 +34,20 @@ import {
loadInstance, loadInstance,
} from "./config.js"; } from "./config.js";
import { CoolifyClient, HttpError } from "./coolify.js"; import { CoolifyClient, HttpError } from "./coolify.js";
import {
type BackupState,
type DestroyExecutor,
executeDestroy,
planDestroy,
readBackupState,
renderAbsentDestroyTarget,
renderDestroyAllRefusal,
renderDestroyPlan,
renderDestroyResult,
renderNoInterlock,
renderNothingDeclaredHere,
renderProjectNotEmptiable,
} from "./destroy.js";
import { import {
type Change, type Change,
type Live, type Live,
@ -98,6 +112,7 @@ const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--
cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>] cast inventory <org>/<repo> --env <env> [--path <dir>] [--project <name>] [--environment <name>] [--resource <m>=<l>]
cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance cast inventory --env <env> [--instance <name>] # no repo: SWEEP the whole instance
cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets] cast inventory --env <env> --emit-draft <dir> [--recipient age1] [--no-secrets]
cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22] cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>] cast smoke <org>/<repo> --env <env> [--project <name>] [--environment <name>]
cast team [--env <env>] cast team [--env <env>]
@ -177,6 +192,18 @@ capture --generated-only (PASS 2 — run it AFTER \`apply\` has created the reso
--force fill a generated name that already holds a REAL value --force fill a generated name that already holds a REAL value
(refused by default it is a silent credential rotation). (refused by default it is a silent credential rotation).
destroy (the only verb that deletes what a manifest declared):
--with-project after the resources, remove the environment and then the
project both only if they are EMPTY. Refused up front when
anything cast did not declare is still in either of them.
MANIFEST-SCOPED, always: it deletes the resources this manifest declares in this
project and this environment, in reverse dependency order, and REPORTS anything
else it finds without touching it. It takes no --project/--environment/--resource
(the coordinates for a box somebody else named by hand), refuses --all outright,
refuses a read-only instance, and refuses any environment whose environments.yaml
binding does not carry \`destroy_allowed: true\`. The last gate is typing the
environment's name at the plan.
inventory --emit-draft (write down what a box has, as a PROPOSAL): inventory --emit-draft (write down what a box has, as a PROPOSAL):
--emit-draft <dir> emit what the sweep saw as a draft of cast's own inputs a --emit-draft <dir> emit what the sweep saw as a draft of cast's own inputs a
manifest per project, env templates, an environments.yaml manifest per project, env templates, an environments.yaml
@ -774,16 +801,36 @@ function readOverrides(names: string[]): Record<string, string> {
// //
// EOF (a closed or empty stdin) resolves to `null` and aborts. Without that // EOF (a closed or empty stdin) resolves to `null` and aborts. Without that
// race, a `< /dev/null` run would hang forever on a question nobody can answer. // race, a `< /dev/null` run would hang forever on a question nobody can answer.
async function confirmCapture(envName: string): Promise<boolean> { async function confirmTypedName(
expected: string,
question: string,
): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout }); const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string | null>((resolve) => { const answer = await new Promise<string | null>((resolve) => {
rl.question( rl.question(question).then(resolve, () => resolve(null));
`\ntype the environment name to write this store (${envName}): `,
).then(resolve, () => resolve(null));
rl.once("close", () => resolve(null)); rl.once("close", () => resolve(null));
}); });
rl.close(); rl.close();
return answer?.trim() === envName; return answer?.trim() === expected;
}
async function confirmCapture(envName: string): Promise<boolean> {
return confirmTypedName(
envName,
`\ntype the environment name to write this store (${envName}): `,
);
}
// The same ceremony, for the verb it was really invented for. Everything said
// above applies twice over here: `destroy` deletes resources and the volumes
// under them, Coolify's delete is a queued job that nothing recalls, and the
// operator has just been shown a plan whose database lines say whether each one
// can ever come back. Typing the environment's name is the act of having read it.
async function confirmDestroy(envName: string): Promise<boolean> {
return confirmTypedName(
envName,
`\ntype the environment name to DESTROY the resources above (${envName}): `,
);
} }
// Everything ONE project run needs that is the same for every project in a // Everything ONE project run needs that is the same for every project in a
@ -1924,6 +1971,210 @@ async function main(): Promise<number> {
await smoke(client, app.uuid); await smoke(client, app.uuid);
return 0; return 0;
} }
// The only verb that deletes what a manifest declared — and, therefore, the
// verb whose REFUSALS are the product. Every gate below is placed as early as
// it can honestly be answered, so that the expensive, irreversible half of this
// command is reached only by a run that has already been told "yes" by the
// state repo, the instance, the team, the project, the manifest, and a human.
if (command === "destroy") {
const { values, positionals } = parseArgs({
args: rest,
allowPositionals: true,
options: {
env: { type: "string" },
state: { type: "string" },
path: { type: "string" },
instance: { type: "string" },
"with-project": { type: "boolean", default: false },
// Declared ONLY so that it can be refused with a sentence. Left out of
// this list, `--all` would die as parseArgs's "Unknown option" — which
// reads like a version skew, invites a retry, and says nothing about why
// a fleet-wide delete is a thing cast does not have. See
// renderDestroyAllRefusal.
all: { type: "boolean", default: false },
},
});
// FIRST, before the usage check even: `cast destroy --env prod --all` has no
// repo positional, and answering it with a usage block would tell an operator
// that the missing piece is the repo name.
if (values.all) {
console.error(renderDestroyAllRefusal());
return 2;
}
const orgRepo = positionals[0];
const envName = values.env;
if (!orgRepo || !envName) {
console.error(USAGE);
return 2;
}
// A checkout cannot decide what prod runs — and for THIS verb, what a
// checkout would be deciding is what gets deleted out of prod. Same rule,
// same string, same refusal as apply's.
if (refusesPathInProd({ env: envName, path: values.path })) {
console.error(PATH_IN_PROD_REFUSAL);
return 2;
}
const stateDir = stateDirFrom(values.state);
const bindingsPath = join(stateDir, "environments.yaml");
const bindings = loadBindings(bindingsPath);
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
// THE INTERLOCK, and it is checked here — before the clone, before the
// instance is opened, before a single call — because it is a fact about the
// state repo and nothing on the wire can change the answer. An environment
// that has not been deliberately opened for destruction refuses at the
// cheapest possible moment, having touched nothing.
if (binding.destroy_allowed !== true) {
console.error(
renderNoInterlock(envName, bindingsPath, binding.destroy_allowed),
);
return 2;
}
// NO --project, NO --environment, NO --resource — see renderAbsentDestroyTarget.
// The project is the one named after the repo, the environment is the one named
// by --env, and the resources are the ones the manifest declares under their own
// names. Every one of those three flags exists to point cast at names SOMEBODY
// ELSE chose in a UI, and a delete does not get to be aimed by them.
const repoShort = orgRepo.split("/")[1];
const projectName = repoShort;
const coolifyEnv = envName;
const checkout = resolveCheckout(orgRepo, {
env: envName,
path: values.path,
});
// The manifest's names, and no secrets: destroy deletes resources, it does not
// resolve a single ${REF}, so it needs no store and no age key (which also means
// a store that was lost with the box being torn down cannot block the teardown).
const declared = manifestResources(checkout, envName).map((r) => ({
kind: r.kind,
name: r.name,
}));
const { instance, client } = openCoolify(
stateDir,
values.instance,
binding,
);
// Both asserts, both before the first read. The read-only refusal is the same
// one apply/smoke/server-add take; the team assert matters even more here than
// it does for them, because a wrong-team token reads back an EMPTY project —
// and an empty project is a plan that deletes nothing while the real one is
// untouched (or, with --with-project, a delete aimed at a project in a team
// nobody checked).
assertWritable(instance, "destroy");
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(
renderAbsentDestroyTarget(lookup, { orgRepo, env: envName }),
);
return 2;
}
const plan = planDestroy(declared, lookup.live);
// What deleting each database COSTS, asked of Coolify rather than assumed
// from the manifest's `backup:` block: the manifest says what was declared,
// and the only thing worth knowing at the prompt is what actually exists and
// whether it ever ran. A failure to read it is `unknown` (see readBackupState)
// — never "none", which is the one direction this must never round in.
for (const target of plan.targets) {
if (target.kind !== "database") continue;
target.backup = await client.databaseBackups(target.uuid).then(
readBackupState,
(err): BackupState => ({
state: "unknown",
reason: err instanceof Error ? err.message : String(err),
}),
);
}
// --with-project, pre-flighted: Coolify refuses to delete a project or an
// environment that still holds anything, and it refuses AFTER the resources are
// gone. Ask both questions now, while nothing has been deleted and the answer is
// still an operator's decision rather than a 400 they read afterwards.
let projectUuid: string | undefined;
if (values["with-project"]) {
projectUuid = await client.projectUuid(projectName);
const otherEnvironments: Array<{ name: string }> = [];
for (const name of await client.environments(projectUuid)) {
if (name === coolifyEnv) continue;
if (!(await client.environmentIsEmpty(projectUuid, name))) {
otherEnvironments.push({ name });
}
}
if (plan.undeclared.length > 0 || otherEnvironments.length > 0) {
console.error(
renderProjectNotEmptiable(
{ project: projectName, environment: coolifyEnv },
{ undeclared: plan.undeclared, otherEnvironments },
),
);
return 2;
}
}
console.log(
renderDestroyPlan(plan, {
orgRepo,
env: envName,
project: projectName,
environment: coolifyEnv,
withProject: values["with-project"],
}),
);
// A destroy with nothing to destroy is a refusal, not a clean run (D-237).
// Under --with-project it is NOT: removing the empty project and environment a
// half-applied first run left behind is exactly the job, and there the emptiness
// is the point rather than the surprise.
if (plan.targets.length === 0 && !values["with-project"]) {
console.error(
renderNothingDeclaredHere(plan, {
orgRepo,
env: envName,
project: projectName,
environment: coolifyEnv,
}),
);
return 2;
}
if (!(await confirmDestroy(envName))) {
console.error("aborted — nothing deleted");
return 2;
}
const uuid = projectUuid;
const exec: DestroyExecutor = {
deleteResource: (t) => client.deleteResource(t.kind, t.uuid),
// Only ever reached under --with-project, which is the only path that
// resolves the project's uuid. The throw is not defensive noise: it is what
// keeps a future caller from wiring these three up with a uuid it never
// fetched, against a project it never looked at.
environmentIsEmpty: () => {
if (!uuid) throw new Error("no project uuid resolved");
return client.environmentIsEmpty(uuid, coolifyEnv);
},
deleteEnvironment: () => {
if (!uuid) throw new Error("no project uuid resolved");
return client.deleteEnvironment(uuid, coolifyEnv);
},
deleteProject: () => {
if (!uuid) throw new Error("no project uuid resolved");
return client.deleteProject(uuid);
},
};
const outcome = await executeDestroy(plan, exec, {
withProject: values["with-project"],
});
console.log(
renderDestroyResult(outcome, {
project: projectName,
environment: coolifyEnv,
}),
);
// A --with-project run that could not finish exits NON-ZERO even though every
// resource it was asked to delete is gone: what the operator asked for did not
// happen in full, and a 0 here would say it did.
return outcome.note ? 2 : 0;
}
if (command === "team") { if (command === "team") {
const { values } = parseArgs({ const { values } = parseArgs({
args: rest, args: rest,

View file

@ -204,6 +204,84 @@ export class CoolifyClient {
); );
} }
// Coolify refuses this itself while the project still holds anything —
// `{"message":"Project has resources, so it cannot be deleted."}`, 400
// (ProjectController@delete_project, v4.1.2, `if (! $project->isEmpty())`,
// where isEmpty() counts every resource in every environment of the project).
// `cast destroy --with-project` refuses first and for the same reason, before
// it asks for the confirmation — see destroy.ts renderProjectNotEmptiable.
async deleteProject(projectUuid: string): Promise<void> {
await this.delete_(`/projects/${projectUuid}`);
}
// Every backup CONFIGURATION for a database, with its executions.
//
// One call answers both halves of the only question that matters at a destroy
// prompt — is this database backed up, and did a backup ever actually land —
// because the route eager-loads them:
// `ScheduledDatabaseBackup::…->with('executions')->where('database_id', …)->get()`
// (DatabasesController@database_backup_details_uuid, v4.1.2). The separate
// `.../backups/{uuid}/executions` route exists and is not needed here.
//
// Returned RAW. destroy.ts's readBackupState is the one place that decides what
// a shape means, because the vendored OpenAPI documents this response as the
// string "Content is very complex. Will be implemented later." and a shape cast
// cannot read has to become "unknown", never "none".
async databaseBackups(uuid: string): Promise<unknown> {
return this.get(`/databases/${encodeURIComponent(uuid)}/backups`);
}
// What a Coolify DELETE removes, made explicit rather than inherited.
//
// All four are query parameters on DELETE /applications|databases|services/{uuid},
// and ALL FOUR DEFAULT TO TRUE — the controller reads them with
// `$request->boolean('delete_volumes', true)` and hands them to DeleteResourceJob
// ({Applications,Databases,Services}Controller@delete_by_uuid, v4.1.2). cast sends
// them anyway: a default is a thing the vendor gets to change, and three of these
// decide whether an operator's data still exists afterwards.
//
// delete_volumes=true the resource's Docker volumes are removed
// (Application::deleteVolumes → `docker volume rm -f`,
// or `docker compose down -v` for a compose app; the
// persistent-storage rows go with them). THIS is what
// makes a database delete unrecoverable, and it is why
// the plan prints a backup line for every database.
// delete_connected_networks=true removes the resource's OWN network — literally
// `docker network disconnect {uuid} coolify-proxy` and
// `docker network rm {uuid}` (Application::deleteConnectedNetworks,
// v4.1.2). The name is the resource's uuid, so this is
// NOT the shared destination network the rest of the box
// hangs off — a multi-project server keeps its network,
// and the two other projects on it keep running. Left at
// false it would leak a dead network per resource.
// delete_configurations=true removes the resource's config directory on the server.
// docker_cleanup=FALSE and this one is deliberately OFF. It is not scoped to
// the resource at all: it dispatches CleanupDocker against
// the SERVER — `docker container prune`, an image prune,
// `docker builder prune -af` (Actions/Server/CleanupDocker,
// v4.1.2) — across every project on that box. The boxes in
// this fleet are multi-project by design and one of them
// hosts third-party production. A teardown of our project
// does not get to prune somebody else's build cache. Coolify
// runs its own scheduled cleanup; it does not need ours.
static readonly DELETE_RESOURCE_QUERY =
"delete_volumes=true&delete_connected_networks=true&delete_configurations=true&docker_cleanup=false";
// The DELETE itself. It ANSWERS BEFORE IT ACTS: the controller dispatches a
// DeleteResourceJob onto the `high` queue and returns 200 "…deletion request
// queued." So a 2xx here means "Coolify accepted the deletion", not "the
// resource is gone" — which is exactly why --with-project waits for the
// environment to actually read back empty before it deletes anything else.
async deleteResource(
kind: "application" | "database" | "service",
uuid: string,
): Promise<void> {
const base = kind === "database" ? "databases" : `${kind}s`;
await this.delete_(
`/${base}/${encodeURIComponent(uuid)}?${CoolifyClient.DELETE_RESOURCE_QUERY}`,
);
}
async deploy(uuid: string): Promise<void> { async deploy(uuid: string): Promise<void> {
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`); await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
} }

600
src/destroy.ts Normal file
View file

@ -0,0 +1,600 @@
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");
}

748
test/destroy.test.ts Normal file
View file

@ -0,0 +1,748 @@
import { spawn } from "node:child_process";
import { 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, describe, expect, it } from "vitest";
import {
DESTROY_ORDER,
type DestroyExecutor,
type DestroyTarget,
executeDestroy,
planDestroy,
readBackupState,
renderDestroyPlan,
} from "../src/destroy.js";
// The refusals ARE the product. `destroy` is the only verb in cast that removes
// something a manifest declared, and the difference between it and a hand
// deletion in the Coolify UI is entirely in what it declines to do — so every
// gate below is a test, and the happy path is the short section at the end.
// ---------------------------------------------------------------- unit tests
describe("DESTROY_ORDER", () => {
it("is the create order, backwards", () => {
expect([...DESTROY_ORDER]).toEqual(["application", "service", "database"]);
});
});
describe("planDestroy", () => {
const live = [
{ kind: "database" as const, name: "postgres", uuid: "d1" },
{ kind: "application" as const, name: "core", uuid: "a1" },
{ kind: "service" as const, name: "metabase", uuid: "s1" },
{ kind: "database" as const, name: "cache", uuid: "d2" },
];
it("orders the deletes applications → services → databases", () => {
const plan = planDestroy(
[
{ kind: "application", name: "core" },
{ kind: "service", name: "metabase" },
{ kind: "database", name: "postgres" },
{ kind: "database", name: "cache" },
],
live,
);
expect(plan.targets.map((t) => `${t.kind} ${t.name}`)).toEqual([
"application core",
"service metabase",
"database cache",
"database postgres",
]);
});
// The single most important property of this verb: a resource the manifest
// does not declare is REPORTED, and never becomes a target. An instance-scoped
// (or even environment-scoped) destroy is one wrong argument away from
// deleting another project's production, and the boxes in this fleet are
// multi-project by design.
it("leaves what the manifest does not declare standing, and reports it", () => {
const plan = planDestroy([{ kind: "application", name: "core" }], live);
expect(plan.targets.map((t) => t.name)).toEqual(["core"]);
expect(plan.undeclared).toEqual([
{ kind: "database", name: "postgres" },
{ kind: "service", name: "metabase" },
{ kind: "database", name: "cache" },
]);
});
// Same name, different kind, is a different resource — and one of them is a
// delete of a database.
it("matches on kind AND name, never on name alone", () => {
const plan = planDestroy(
[{ kind: "application", name: "postgres" }],
[{ kind: "database", name: "postgres", uuid: "d1" }],
);
expect(plan.targets).toEqual([]);
expect(plan.absent).toEqual([{ kind: "application", name: "postgres" }]);
expect(plan.undeclared).toEqual([{ kind: "database", name: "postgres" }]);
});
it("reports what the manifest declares and the box does not have", () => {
const plan = planDestroy(
[
{ kind: "application", name: "core" },
{ kind: "application", name: "worker" },
],
[{ kind: "application", name: "core", uuid: "a1" }],
);
expect(plan.absent).toEqual([{ kind: "application", name: "worker" }]);
});
});
describe("readBackupState", () => {
it("reads the schedule and the last execution that actually landed", () => {
const state = readBackupState([
{
uuid: "b1",
frequency: "0 2 * * *",
enabled: true,
executions: [
{ created_at: "2026-07-10T02:00:03Z", status: "success" },
{ created_at: "2026-07-13T02:00:11Z", status: "success" },
{ created_at: "2026-07-11T02:00:07Z", status: "failed" },
],
},
]);
expect(state.state).toBe("scheduled");
if (state.state !== "scheduled") return;
expect(state.schedules[0].frequency).toBe("0 2 * * *");
// The NEWEST, not the first in the array — the ordering of the response is
// not a thing to depend on when the answer decides whether a delete is
// recoverable.
expect(state.schedules[0].last?.at).toBe("2026-07-13T02:00:11Z");
expect(state.schedules[0].executions).toBe(3);
});
it("says NONE only when Coolify actually says there are no backups", () => {
expect(readBackupState([])).toEqual({ state: "none" });
});
// The direction that must never round the wrong way. Anything cast cannot read
// is UNKNOWN — an unreadable backup configuration must never render as "this
// database has no backups" (which reads as "expected, go ahead"), and equally
// never as "backed up" (which reads as "recoverable").
it("says UNKNOWN, never NONE, for a shape it cannot read", () => {
for (const raw of [
null,
undefined,
"Content is very complex. Will be implemented later.",
{ message: "Database not found." },
42,
]) {
const state = readBackupState(raw);
expect(state.state).toBe("unknown");
}
});
it("accepts an enveloped response rather than calling it unknown", () => {
const state = readBackupState({
data: [{ frequency: "0 3 * * *", executions: [] }],
});
expect(state.state).toBe("scheduled");
});
it("counts a schedule that has never run as a schedule that has never run", () => {
const state = readBackupState([{ frequency: "0 3 * * *", executions: [] }]);
if (state.state !== "scheduled") throw new Error("expected scheduled");
expect(state.schedules[0].executions).toBe(0);
expect(state.schedules[0].last).toBeUndefined();
});
});
describe("renderDestroyPlan", () => {
const ctx = {
orgRepo: "heavy-duty/incubator",
env: "staging",
project: "incubator",
environment: "staging",
withProject: false,
};
it("says, for every database, whether deleting it is recoverable", () => {
const targets: DestroyTarget[] = [
{
kind: "database",
name: "postgres",
uuid: "d1",
backup: {
state: "scheduled",
schedules: [
{
frequency: "0 2 * * *",
enabled: true,
last: { at: "2026-07-13T02:00:11Z", status: "success" },
executions: 12,
},
],
},
},
{
kind: "database",
name: "cache",
uuid: "d2",
backup: { state: "none" },
},
{
kind: "database",
name: "ledger",
uuid: "d3",
backup: { state: "unknown", reason: "GET … → 404" },
},
];
const out = renderDestroyPlan({ targets, absent: [], undeclared: [] }, ctx);
expect(out).toContain("0 2 * * *");
expect(out).toContain("2026-07-13T02:00:11Z");
// The two that cannot be brought back say so, in the word an operator reads
// at 2am.
expect(out).toContain("backup schedule: NONE");
expect(out).toContain("backup schedule: UNKNOWN");
expect(out.match(/UNRECOVERABLE/g)?.length).toBe(2);
// And the one that CAN does not.
const backedUp = out.split("cache")[0];
expect(backedUp).not.toContain("UNRECOVERABLE");
});
it("names what it is leaving standing", () => {
const out = renderDestroyPlan(
{
targets: [{ kind: "application", name: "core", uuid: "a1" }],
absent: [],
undeclared: [{ kind: "service", name: "clients-site", uuid: "s9" }],
} as never,
ctx,
);
expect(out).toContain("LEFT STANDING");
expect(out).toContain("clients-site");
});
});
// A fake Coolify for the executor: it records the order of every call, which is
// the thing being asserted.
function fakeExecutor(opts: { emptyAfter?: number; failOn?: string } = {}) {
const calls: string[] = [];
let polls = 0;
const exec: DestroyExecutor = {
async deleteResource(t) {
if (opts.failOn === t.name) throw new Error(`boom: ${t.name}`);
calls.push(`delete ${t.kind} ${t.name}`);
},
async environmentIsEmpty() {
polls += 1;
return polls > (opts.emptyAfter ?? 0);
},
async deleteEnvironment() {
calls.push("delete environment");
},
async deleteProject() {
calls.push("delete project");
},
};
return { exec, calls };
}
const plan = (targets: DestroyTarget[]) => ({
targets,
absent: [],
undeclared: [],
});
const TARGETS: DestroyTarget[] = [
{ kind: "application", name: "core", uuid: "a1" },
{ kind: "service", name: "metabase", uuid: "s1" },
{ kind: "database", name: "postgres", uuid: "d1" },
];
const NO_WAIT = { attempts: 3, intervalMs: 0, sleep: async () => {} };
describe("executeDestroy", () => {
it("deletes in reverse dependency order", async () => {
const { exec, calls } = fakeExecutor();
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: false,
});
expect(calls).toEqual([
"delete application core",
"delete service metabase",
"delete database postgres",
]);
expect(outcome.deleted).toHaveLength(3);
expect(outcome.projectDeleted).toBe(false);
});
it("stops at the first failure rather than carrying on down the order", async () => {
const { exec, calls } = fakeExecutor({ failOn: "metabase" });
await expect(
executeDestroy(plan(TARGETS), exec, { withProject: false }),
).rejects.toThrow("boom: metabase");
// The database is still there. A teardown that continues past an unexplained
// failure is deleting the thing the failed one may still depend on.
expect(calls).toEqual(["delete application core"]);
});
// Coolify's DELETE queues a job and answers immediately, so "I deleted them"
// is not the same claim as "they are gone" — and only one of the two makes it
// safe to delete the project.
it("waits for Coolify's queue before it removes the environment and project", async () => {
const { exec, calls } = fakeExecutor({ emptyAfter: 2 });
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: true,
wait: NO_WAIT,
});
expect(calls).toEqual([
"delete application core",
"delete service metabase",
"delete database postgres",
"delete environment",
"delete project",
]);
expect(outcome.projectDeleted).toBe(true);
expect(outcome.note).toBeUndefined();
});
it("will not delete a project it cannot see is empty", async () => {
const { exec, calls } = fakeExecutor({ emptyAfter: 99 });
const outcome = await executeDestroy(plan(TARGETS), exec, {
withProject: true,
wait: NO_WAIT,
});
expect(calls).not.toContain("delete environment");
expect(calls).not.toContain("delete project");
expect(outcome.projectDeleted).toBe(false);
expect(outcome.note).toContain("still not empty");
});
});
// ----------------------------------------------------------- end-to-end tests
//
// The real CLI (`dist/cli.js`), against a stub Coolify that MUTATES: a delete
// removes the resource, so the environment really does read back empty
// afterwards, and --with-project's wait is exercised rather than mocked away.
type Stub = {
url: string;
calls: string[];
close: () => Promise<void>;
};
const stubs: Stub[] = [];
type Resource = { name: string; uuid: string };
type Env = {
applications: Resource[];
postgresqls: Resource[];
redis: Resource[];
services: Resource[];
};
const env = (e: Partial<Env> = {}): Env => ({
applications: [],
postgresqls: [],
redis: [],
services: [],
...e,
});
async function stubCoolify(
opts: {
environments?: Record<string, Env>;
backups?: Record<string, unknown>;
projects?: Array<{ uuid: string; name: string }>;
} = {},
): Promise<Stub> {
const environments = opts.environments ?? {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
};
const projects = opts.projects ?? [{ uuid: "p1", name: "incubator" }];
const backups = opts.backups ?? {
d1: [
{
uuid: "b1",
frequency: "0 2 * * *",
enabled: true,
executions: [{ created_at: "2026-07-13T02:00:11Z", status: "success" }],
},
],
d2: [],
};
const calls: string[] = [];
const server = createServer((req, res) => {
const url = (req.url ?? "").replace("/api/v1", "");
const path = url.split("?")[0];
calls.push(`${req.method} ${url}`);
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" });
if (req.method === "GET" && path === "/projects") return json(projects);
if (path === "/projects/p1/environments")
return json(Object.keys(environments).map((name) => ({ name })));
const envMatch = path.match(/^\/projects\/p1\/([^/]+)$/);
if (req.method === "GET" && envMatch) {
const found = environments[envMatch[1]];
if (!found) {
res.writeHead(404);
return res.end("{}");
}
return json(found);
}
const backupMatch = path.match(/^\/databases\/([^/]+)\/backups$/);
if (req.method === "GET" && backupMatch)
return json(backups[backupMatch[1]] ?? []);
const del = path.match(/^\/(applications|databases|services)\/([^/]+)$/);
if (req.method === "DELETE" && del) {
const uuid = del[2];
for (const e of Object.values(environments)) {
for (const key of [
"applications",
"postgresqls",
"redis",
"services",
] as const) {
e[key] = e[key].filter((r) => r.uuid !== uuid);
}
}
return json({ message: "deletion request queued" });
}
if (
req.method === "DELETE" &&
path.startsWith("/projects/p1/environments/")
)
return json({ message: "Environment deleted." });
if (req.method === "DELETE" && path === "/projects/p1")
return json({ message: "Project deleted." });
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}`,
calls,
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()));
});
// core (app), postgres + cache (databases). `metabase` is deliberately NOT here:
// wherever the stub serves it, it is a resource created outside cast.
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"]
databases:
postgres: { type: postgresql, version: "16" }
cache: { type: redis }
`;
function fixture(
url: string,
opts: { destroyAllowed?: boolean | undefined; readOnly?: boolean } = {},
) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
mkdirSync(join(checkout, ".infra"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
writeFileSync(
join(state, ".coolify.env"),
[
`COOLIFY_BASE_URL="${url}"`,
'COOLIFY_ACCESS_TOKEN="t"',
...(opts.readOnly ? ["COOLIFY_READ_ONLY=true"] : []),
"",
].join("\n"),
);
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: staging-box",
" team: { id: 0, name: Root Team }",
...(opts.destroyAllowed === undefined
? []
: [` destroy_allowed: ${opts.destroyAllowed}`]),
"github_apps:",
" incubator: hdb-coolify",
"",
].join("\n"),
);
return { checkout, state };
}
function runDestroy(
args: string[],
opts: { stdin?: string } = {},
): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn("node", ["dist/cli.js", "destroy", ...args], {
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.stdin.end(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,
];
const deletes = (stub: Stub) =>
stub.calls.filter((c) => c.startsWith("DELETE"));
describe("cast destroy (end to end): the refusals", () => {
// The one verb that must never iterate a fleet.
it("refuses --all, always, and before it opens anything", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--all"], { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("--all is not a thing destroy does");
expect(stub.calls).toEqual([]);
});
// The interlock lives in state, not in argv — and absent means refuse.
it("refuses an environment with no destroy_allowed binding", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url);
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("does not allow it");
expect(r.output).toContain("destroy_allowed: true");
expect(r.output).toContain("(absent)");
// Nothing was even asked of Coolify: the state repo said no.
expect(stub.calls).toEqual([]);
});
it("refuses destroy_allowed: false as loudly as an absent one", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: false });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain("destroy_allowed: false");
expect(stub.calls).toEqual([]);
});
// The same refusal apply/smoke/server-add take, from the same assert, with the
// same exit code (assertWritable throws, and main's handler exits 1) — an
// instance declared for inspection cannot be written to, whatever its token
// would permit.
it("refuses a read-only instance", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true, readOnly: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(1);
expect(r.output).toContain("refusing to destroy");
expect(r.output).toContain("read-only");
expect(deletes(stub)).toEqual([]);
});
// D-237, for the verb whose empty plan is "delete nothing" — an absent project
// must never read as a clean teardown.
it("refuses an absent project, and names what IS there", async () => {
const stub = await stubCoolify({
projects: [{ uuid: "p9", name: "clients-site" }],
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain('no project named "incubator"');
expect(r.output).toContain("clients-site");
expect(deletes(stub)).toEqual([]);
});
it("refuses when the manifest declares nothing this environment holds", async () => {
const stub = await stubCoolify({
environments: {
staging: env({ services: [{ name: "metabase", uuid: "s1" }] }),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(2);
expect(r.output).toContain(
"none of the 3 resource(s) the manifest declares",
);
expect(r.output).toContain("metabase");
expect(deletes(stub)).toEqual([]);
});
it("aborts on anything but the environment's own name, typed", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
for (const answer of ["y\n", "yes\n", "prod\n", ""]) {
const r = await runDestroy(base(f), { stdin: answer });
expect(r.code).toBe(2);
expect(r.output).toContain("aborted — nothing deleted");
}
expect(deletes(stub)).toEqual([]);
});
// --with-project, blocked by something cast did not declare. Refused BEFORE the
// confirmation, because Coolify would refuse the project delete after the
// resources were already gone.
it("refuses --with-project while an undeclared resource is in the way", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
services: [{ name: "metabase", uuid: "s1" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(2);
expect(r.output).toContain("would not be empty");
expect(r.output).toContain("metabase");
expect(deletes(stub)).toEqual([]);
});
it("refuses --with-project while another environment of it holds resources", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
production: env({ applications: [{ name: "core", uuid: "a9" }] }),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(2);
expect(r.output).toContain("would not be empty");
expect(r.output).toContain("production");
expect(deletes(stub)).toEqual([]);
});
});
describe("cast destroy (end to end): what it does when it does it", () => {
it("deletes exactly what the manifest declares, in reverse order, and says what it left", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
// Created outside cast. It survives this run.
services: [{ name: "metabase", uuid: "s1" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
const del = deletes(stub).map((c) => c.split("?")[0]);
expect(del).toEqual([
"DELETE /applications/a1",
"DELETE /databases/d2",
"DELETE /databases/d1",
]);
// The service nobody declared is still standing, and was said out loud.
expect(del).not.toContain("DELETE /services/s1");
expect(r.output).toContain("LEFT STANDING");
expect(r.output).toContain("metabase");
// The query cast sends, rather than the defaults it would inherit: volumes
// and the resource's own network go, a server-wide docker prune does not.
const query = deletes(stub)[0];
expect(query).toContain("delete_volumes=true");
expect(query).toContain("delete_connected_networks=true");
expect(query).toContain("delete_configurations=true");
expect(query).toContain("docker_cleanup=false");
});
it("says at the prompt which database can be brought back and which cannot", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
// postgres (d1) has a schedule that has run; cache (d2) has none at all.
expect(r.output).toContain("0 2 * * *");
expect(r.output).toContain("2026-07-13T02:00:11Z");
expect(r.output).toContain("backup schedule: NONE");
expect(r.output).toContain("UNRECOVERABLE");
});
// A backups route cast cannot read must never make a backed-up database read
// as an unbacked one, or the reverse. It reads as UNKNOWN, and says so.
it("prints UNKNOWN when the backups route cannot be read", async () => {
const stub = await stubCoolify({
backups: { d1: "not json we know", d2: [] },
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy(base(f), { stdin: "staging\n" });
expect(r.code).toBe(0);
expect(r.output).toContain("backup schedule: UNKNOWN");
});
it("--with-project removes the environment and the project once they are empty", async () => {
const stub = await stubCoolify({
environments: {
staging: env({
applications: [{ name: "core", uuid: "a1" }],
postgresqls: [{ name: "postgres", uuid: "d1" }],
redis: [{ name: "cache", uuid: "d2" }],
}),
},
});
const f = fixture(stub.url, { destroyAllowed: true });
const r = await runDestroy([...base(f), "--with-project"], {
stdin: "staging\n",
});
expect(r.code).toBe(0);
const del = deletes(stub).map((c) => c.split("?")[0]);
expect(del).toEqual([
"DELETE /applications/a1",
"DELETE /databases/d2",
"DELETE /databases/d1",
"DELETE /projects/p1/environments/staging",
"DELETE /projects/p1",
]);
expect(r.output).toContain('deleted project "incubator"');
});
});