feat(destroy): cast destroy — a scoped, state-gated teardown verb (#43) #59

Merged
dan-claude-bot merged 1 commit from feat/destroy into main 2026-07-14 22:53:59 +00:00
dan-claude-bot commented 2026-07-14 22:34:47 +00:00 (Migrated from github.com)

The gap

apply fails closed on an immutable-field change and tells you to "resolve manually (runbook act)". There is no runbook, and in practice that sentence means a hand deletion in the Coolify UI — unscoped, unconfirmed, against an instance whose token can see every project on it. It is also the only path back to zero from a half-applied first run, and the reason the DR drill has never been rehearsed.

cast destroy is that act, scoped and gated.

cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project]

The shape

Manifest-scoped. It deletes the resources the manifest declares, in that project and that environment, in reverse dependency order — applications → services → databases (DESTROY_ORDER, defined locally in src/destroy.ts). Resources it finds and did not declare are reported and LEFT STANDING, loudly: that report is how something created outside cast gets discovered, and deleting it would make this an environment wipe. It is not an instance wipe (the boxes in this fleet are multi-project by design — one hosts two third-party client sites) and it is not a flag on apply (apply never deletes is what makes apply safe to run on a schedule; apply.ts and diff.ts are untouched).

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. Consequence, stated deliberately: an environment that apply adopted under a Coolify-side alias cannot be torn down by this verb. That is the safe direction, and it can be revisited once destroy has been used a few times.

The plan says what the delete costs. Every database line carries its backup schedule and when the last backup actually landed:

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):

Every refusal, and how it is enforced

refusal enforced test
--all, always --all is parsed (rather than dying as parseArgs's "Unknown option", which reads like version skew and invites a retry) purely so it can be refused with a sentence — before anything is opened. refuses --all, always, and before it opens anything (asserts the stub Coolify received zero calls)
COOLIFY_READ_ONLY instance assertWritable(instance, "destroy") — the same assert apply/smoke/server add take, same exit code refuses a read-only instance
absent project (D-237) renderAbsentDestroyTarget — destroy's own copy of the renderAbsentTarget shape, because that one ends by offering --project/--environment and destroy has neither. An absent target reads back exactly like an empty one, and an empty one gives this verb a plan that deletes nothing — a clean-looking teardown of an environment that is still standing. refuses an absent project, and names what IS there
manifest declares nothing this environment holds renderNothingDeclaredHere — the same lie one level deeper (cf. capture's renderAbsentResources). Names what is there. refuses when the manifest declares nothing this environment holds
destroy_allowed absent or false environments.<env>.destroy_allowed: true in environments.yaml (new field in BindingsSchema, next to forbidden_var_patterns). Checked before the clone, before the instance is opened, before a single call. Absent = refuse. refuses an environment with no destroy_allowed binding, refuses destroy_allowed: false as loudly as an absent one (both assert zero calls to Coolify)
anything but the environment's name, typed confirmDestroyconfirmCapture's ceremony, factored into confirmTypedName (capture's behavior and message unchanged) aborts on anything but the environment's own name, typed (y, yes, a different env, EOF)
--with-project while anything undeclared is in the way pre-flighted before the confirmation prompt: the environment's undeclared resources, plus every other environment of the project. Coolify refuses those deletes too (400 Project has resources…) — but it refuses them after your resources are gone. refuses --with-project while an undeclared resource is in the way, …while another environment of it holds resources

The interlock lives in state, not in argv. 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. destroy_allowed 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 prod carries real data, and from then on destroying prod costs a PR.

Coolify DELETE semantics (read from source, not from the vendored spec)

Verified against coollabsio/coolify v4.1.2ApplicationsController.php, DatabasesController.php, ServicesController.php, ProjectController.php, app/Jobs/DeleteResourceJob.php, app/Models/Application.php, app/Actions/Server/CleanupDocker.php. Written up in docs/semantics.mdTeardown.

DELETE /applications|databases|services/{uuid} takes four query parameters and all four default to true ($request->boolean('delete_volumes', true)DeleteResourceJob). cast sends them explicitly — a default is a thing the vendor gets to change, and three of them decide whether the data still exists:

  • delete_volumes=truedocker volume rm -f per persistent storage (docker compose down -v for a compose app), plus the storage rows. This is what makes a database delete unrecoverable, and it is why every database line in the plan carries a backup line.
  • delete_connected_networks=true — literally docker network disconnect <uuid> coolify-proxy + docker network rm <uuid> (Application::deleteConnectedNetworks). The network is named for the resource's own 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 other projects on it keep running. Left false it would leak a dead network per resource.
  • delete_configurations=true — the resource's config directory on the server.
  • docker_cleanup=FALSE — deliberately off, and the one place cast departs from Coolify's defaults. 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), across every project on that box. These boxes host 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 the four flags, the job also deletes the resource's env vars, file storages, and — for a database — SSL certs and scheduled-backup configurations. Backups already in S3 are untouched.

The delete is asynchronous. The controller queues DeleteResourceJob and answers 200 {"message":"…deletion request queued."}. A 2xx means accepted, not gone — so --with-project polls GET /projects/{uuid}/{env} until the environment actually reads back empty before deleting the environment and the project, rather than racing the queue into a 400. If it never empties, the resources stay deleted, cast does not delete a project it cannot see is empty, it says so, and it exits non-zero.

Project/environment deletes are guarded by Coolify itself: 400 Project has resources, so it cannot be deleted. / 400 Environment has resources… (ProjectController@delete_project / @delete_environment, both isEmpty()). cast refuses first, before the prompt.

Backups: GET /databases/{uuid}/backups

Returns the backup configs with executions eager-loaded (ScheduledDatabaseBackup::…->with('executions')->get()), so one call answers both halves of the question — is it backed up, and did a backup ever land? The .../backups/{uuid}/executions route exists and is not needed. The vendored OpenAPI documents this response as the literal string "Content is very complex. Will be implemented later.", so readBackupState parses the source's shape (plus two common envelopes) and refuses to guess: an unrecognized shape or an erroring route prints backup schedule: UNKNOWN with the reason and is treated as unrecoverable. It never rounds down to NONE. PR #51 is settling this route's live response shape in parallel — if it lands a different envelope, readBackupState is the single place that has to learn about it, and until then it degrades to unknown rather than to a lie.

Tests (+29; 294 → 323 green)

test/destroy.test.ts — unit + end-to-end against a mutating stub Coolify (a DELETE really removes the resource, so --with-project's wait is exercised rather than mocked away). No live instance is touched anywhere.

  • ordering (DESTROY_ORDER is the create order backwards; planDestroy sorts by it), kind+name matching (an application named postgres is not the database named postgres), undeclared resources never become targets
  • readBackupState: newest execution wins regardless of array order; [] → NONE; unreadable shapes → UNKNOWN, never NONE; a schedule that has never run is reported as never having run
  • executeDestroy: reverse order; stops at the first failure rather than carrying on down the order; waits for Coolify's queue before removing the environment/project; will not delete a project it cannot see is empty
  • every refusal above, end to end, each asserting that no DELETE was issued
  • the happy path: exactly the three declared resources, in reverse order, with delete_volumes=true&delete_connected_networks=true&delete_configurations=true&docker_cleanup=false, the undeclared service still standing and reported, and the two database backup lines (one recoverable, one not)

Notes for the reviewer

  • --path is supported (the issue's shape did not list it). Every other verb has it, refusesPathInProd already bans it in prod (a checkout cannot decide what prod runs — least of all what gets deleted from it), and without it the end-to-end tests could not run without cloning. Non-prod only, and it can only ever narrow to resources that already exist in this project's environment.
  • DESTROY_ORDER is local to src/destroy.ts. PR #45 owns the forward create-order in apply.ts; one shared array with a .reverse() at one call site is a constant whose meaning depends on which caller you read last, and the one that gets it backwards deletes a database first. Follow-up: unify the two once #45 lands, in a place that can honestly own both directions.
  • apply.ts and diff.ts are not touched. confirmCapture's behavior and prompt are unchanged (its body now calls the shared confirmTypedName).
  • apply --recreate is deliberately NOT built — the issue defers it, and it is worth not building until destroy has been used a few times.
  • Read-only instances exit 1 (not 2): assertWritable throws, exactly as it does for apply/smoke/server add. Left consistent with the existing verbs rather than special-cased here.

Closes #43.

## The gap `apply` fails closed on an immutable-field change and tells you to *"resolve manually (runbook act)"*. There is no runbook, and in practice that sentence means **a hand deletion in the Coolify UI** — unscoped, unconfirmed, against an instance whose token can see every project on it. It is also the only path back to zero from a half-applied first run, and the reason the DR drill has never been rehearsed. `cast destroy` is that act, scoped and gated. ```sh cast destroy <org>/<repo> --env <env> [--instance <name>] [--path <dir>] [--with-project] ``` ## The shape **Manifest-scoped.** It deletes the resources the manifest declares, in that project and that environment, in **reverse dependency order** — applications → services → databases (`DESTROY_ORDER`, defined locally in `src/destroy.ts`). Resources it finds and did not declare are **reported and LEFT STANDING**, loudly: that report is how something created outside cast gets discovered, and deleting it would make this an environment wipe. It is not an instance wipe (the boxes in this fleet are multi-project by design — one hosts two third-party client sites) and it is **not a flag on `apply`** (`apply never deletes` is what makes apply safe to run on a schedule; `apply.ts` and `diff.ts` are untouched). **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. Consequence, stated deliberately: an environment that `apply` adopted under a Coolify-side alias cannot be torn down by this verb. That is the safe direction, and it can be revisited once destroy has been used a few times. **The plan says what the delete costs.** Every database line carries its backup schedule and when the last backup actually landed: ``` 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): ``` ## Every refusal, and how it is enforced | refusal | enforced | test | |---|---|---| | **`--all`, always** | `--all` is *parsed* (rather than dying as parseArgs's "Unknown option", which reads like version skew and invites a retry) purely so it can be refused with a sentence — before anything is opened. | `refuses --all, always, and before it opens anything` (asserts the stub Coolify received **zero** calls) | | **`COOLIFY_READ_ONLY` instance** | `assertWritable(instance, "destroy")` — the same assert `apply`/`smoke`/`server add` take, same exit code | `refuses a read-only instance` | | **absent project** (D-237) | `renderAbsentDestroyTarget` — destroy's own copy of the `renderAbsentTarget` shape, because that one ends by offering `--project`/`--environment` and destroy has neither. An absent target reads back exactly like an empty one, and an empty one gives *this* verb a plan that deletes nothing — a clean-looking teardown of an environment that is still standing. | `refuses an absent project, and names what IS there` | | **manifest declares nothing this environment holds** | `renderNothingDeclaredHere` — the same lie one level deeper (cf. `capture`'s `renderAbsentResources`). Names what *is* there. | `refuses when the manifest declares nothing this environment holds` | | **`destroy_allowed` absent or false** | `environments.<env>.destroy_allowed: true` in `environments.yaml` (new field in `BindingsSchema`, next to `forbidden_var_patterns`). Checked **before the clone, before the instance is opened, before a single call**. Absent = refuse. | `refuses an environment with no destroy_allowed binding`, `refuses destroy_allowed: false as loudly as an absent one` (both assert zero calls to Coolify) | | **anything but the environment's name, typed** | `confirmDestroy` — `confirmCapture`'s ceremony, factored into `confirmTypedName` (capture's behavior and message unchanged) | `aborts on anything but the environment's own name, typed` (`y`, `yes`, a different env, EOF) | | **`--with-project` while anything undeclared is in the way** | pre-flighted *before the confirmation prompt*: the environment's undeclared resources, plus every **other** environment of the project. Coolify refuses those deletes too (`400 Project has resources…`) — but it refuses them *after* your resources are gone. | `refuses --with-project while an undeclared resource is in the way`, `…while another environment of it holds resources` | **The interlock lives in state, not in argv.** 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. `destroy_allowed` 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 prod carries real data**, and from then on destroying prod costs a PR. ## Coolify DELETE semantics (read from source, not from the vendored spec) Verified against `coollabsio/coolify` **v4.1.2** — `ApplicationsController.php`, `DatabasesController.php`, `ServicesController.php`, `ProjectController.php`, `app/Jobs/DeleteResourceJob.php`, `app/Models/Application.php`, `app/Actions/Server/CleanupDocker.php`. Written up in `docs/semantics.md` → *Teardown*. `DELETE /applications|databases|services/{uuid}` takes four query parameters and **all four default to `true`** (`$request->boolean('delete_volumes', true)` → `DeleteResourceJob`). cast sends them explicitly — a default is a thing the vendor gets to change, and three of them decide whether the data still exists: - **`delete_volumes=true`** — `docker volume rm -f` per persistent storage (`docker compose down -v` for a compose app), plus the storage rows. **This is what makes a database delete unrecoverable**, and it is why every database line in the plan carries a backup line. - **`delete_connected_networks=true`** — literally `docker network disconnect <uuid> coolify-proxy` + `docker network rm <uuid>` (`Application::deleteConnectedNetworks`). The network is named for the **resource's own 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 other projects on it keep running. Left `false` it would leak a dead network per resource. - **`delete_configurations=true`** — the resource's config directory on the server. - **`docker_cleanup=FALSE`** — deliberately off, and the one place cast departs from Coolify's defaults. 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`), across every project on that box. These boxes host 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 the four flags, the job also deletes the resource's **env vars**, file storages, and — for a database — SSL certs and **scheduled-backup configurations**. Backups already in S3 are untouched. **The delete is asynchronous.** The controller queues `DeleteResourceJob` and answers `200 {"message":"…deletion request queued."}`. A 2xx means *accepted*, not *gone* — so `--with-project` polls `GET /projects/{uuid}/{env}` until the environment actually reads back empty before deleting the environment and the project, rather than racing the queue into a 400. If it never empties, the resources stay deleted, cast **does not** delete a project it cannot see is empty, it says so, and it exits non-zero. **Project/environment deletes are guarded by Coolify itself:** `400 Project has resources, so it cannot be deleted.` / `400 Environment has resources…` (`ProjectController@delete_project` / `@delete_environment`, both `isEmpty()`). cast refuses first, before the prompt. ### Backups: `GET /databases/{uuid}/backups` Returns the backup configs with executions **eager-loaded** (`ScheduledDatabaseBackup::…->with('executions')->get()`), so one call answers both halves of the question — *is it backed up, and did a backup ever land?* The `.../backups/{uuid}/executions` route exists and is not needed. The vendored OpenAPI documents this response as the literal string *"Content is very complex. Will be implemented later."*, so `readBackupState` parses the source's shape (plus two common envelopes) and **refuses to guess**: an unrecognized shape or an erroring route prints `backup schedule: UNKNOWN` **with the reason** and is treated as unrecoverable. It never rounds down to `NONE`. **PR #51 is settling this route's live response shape in parallel** — if it lands a different envelope, `readBackupState` is the single place that has to learn about it, and until then it degrades to `unknown` rather than to a lie. ## Tests (+29; 294 → **323 green**) `test/destroy.test.ts` — unit + end-to-end against a **mutating** stub Coolify (a DELETE really removes the resource, so `--with-project`'s wait is exercised rather than mocked away). No live instance is touched anywhere. - ordering (`DESTROY_ORDER` is the create order backwards; `planDestroy` sorts by it), kind+name matching (an `application` named `postgres` is not the `database` named `postgres`), undeclared resources never become targets - `readBackupState`: newest execution wins regardless of array order; `[]` → NONE; unreadable shapes → UNKNOWN, never NONE; a schedule that has never run is reported as never having run - `executeDestroy`: reverse order; **stops at the first failure** rather than carrying on down the order; waits for Coolify's queue before removing the environment/project; **will not delete a project it cannot see is empty** - every refusal above, end to end, each asserting that **no DELETE was issued** - the happy path: exactly the three declared resources, in reverse order, with `delete_volumes=true&delete_connected_networks=true&delete_configurations=true&docker_cleanup=false`, the undeclared service still standing and reported, and the two database backup lines (one recoverable, one not) ## Notes for the reviewer - **`--path` is supported** (the issue's shape did not list it). Every other verb has it, `refusesPathInProd` already bans it in prod (a checkout cannot decide what prod runs — least of all what gets deleted from it), and without it the end-to-end tests could not run without cloning. Non-prod only, and it can only ever *narrow* to resources that already exist in this project's environment. - **`DESTROY_ORDER` is local to `src/destroy.ts`.** PR #45 owns the forward create-order in `apply.ts`; one shared array with a `.reverse()` at one call site is a constant whose meaning depends on which caller you read last, and the one that gets it backwards deletes a database first. **Follow-up: unify the two once #45 lands**, in a place that can honestly own both directions. - `apply.ts` and `diff.ts` are not touched. `confirmCapture`'s behavior and prompt are unchanged (its body now calls the shared `confirmTypedName`). - **`apply --recreate` is deliberately NOT built** — the issue defers it, and it is worth *not* building until destroy has been used a few times. - Read-only instances exit **1** (not 2): `assertWritable` throws, exactly as it does for `apply`/`smoke`/`server add`. Left consistent with the existing verbs rather than special-cased here. Closes #43.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#59
No description provided.