cast destroy — a scoped teardown verb, because "resolve manually" is a UI deletion with no guardrails #43

Closed
opened 2026-07-14 17:44:13 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-14 17:44:13 +00:00 (Migrated from github.com)

The gap, in cast's own words

apply fails closed on an immutable-field change, and tells you what to do about it (apply.ts:74):

cannot update in place: <kind> <name> field(s) <fields> — apply never recreates resources; resolve manually (runbook act)

There is no runbook, and "resolve manually" is a hand-deletion in the Coolify UI — unscoped, unconfirmed, and against an instance whose token can see every project on it. Battle-testing the first apply means doing that repeatedly. In practice it means deleting the wrong project sometimes, which is how this issue got filed.

The same gap shows up in three other places:

  • A half-applied first run. A create that 422s partway leaves a project and an environment behind, and no way back to zero.
  • Immutable fields. build_pack, type, version, and placement (destination_uuid) are create-time. Getting one wrong means delete + recreate — the only path cast offers is "go do it by hand".
  • The DR drill. "Rebuild the control plane from state" has never been rehearsed partly because there is no cheap reset. A teardown verb makes the drill repeatable instead of a one-shot on a throwaway box.

What it must NOT be

Not an instance wipe. Servers here are multi-project by design — Coolify gates servers by team, so a box per project is not affordable, and projects share a box with a Docker network each. An instance-scoped destroy on such a box is one wrong --instance away from deleting somebody else's production. The legacy box in this fleet hosts two third-party client sites. There is no use for instance-wide destruction that project-scoping does not cover.

Not a flag on apply. apply never deletes is the invariant that makes it safe to run on a schedule — orphan env vars are reported, never removed; orphan resources are counted, never touched. A --prune turns one stale manifest plus one routine apply into a wiped production. Separate verb, separate gates, or nothing.

Proposed shape

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

Manifest-scoped. It deletes only the resources the manifest declares, in that project and that environment. Resources it finds but did not declare are reported and left standing — loudly, because that report is also how you discover something was created outside cast.

Reverse dependency order — applications, then services, then databases.

Plan, then typed confirmation of the environment name — the same ceremony capture already uses. Names and kinds, never values.

Refuses, rather than no-ops:

  • an absent project → refuse and name what is there (the D-237 family: an absent target must never read as a clean empty plan, and that is doubly true for a verb whose plan is "delete nothing")
  • a COOLIFY_READ_ONLY instance → refuse, as apply/smoke/server add already do
  • --allrefuse always. This is the one verb that must never iterate a fleet.

A prod interlock that lives in state, not in argv. A --yes-style flag is not a gate; it is a thing you type without reading. Put it where a human has to commit it:

environments:
  prod:
    destroy_allowed: true    # absent = destroy refuses. Removed at cutover, forever.

Same reasoning as forbidden_var_patterns living in private state rather than the product manifest: a change on one side must not be able to lower its own guard. It is true on prod today, while the box is empty and being battle-tested. The cutover checklist deletes the line the moment prod carries real data — and from then on destroying prod requires an operator to edit state, commit it, and merge it. That is the correct amount of friction for a verb that ends companies.

Say what is being destroyed, in terms of what it costs. A database line should carry whether it has a backup schedule and when the last backup landed — the difference between "recreate this" and "this is unrecoverable" is exactly the thing the operator needs at the confirmation prompt and cannot see in a UUID.

Not in scope, but adjacent

Once destroy exists, the natural follow-on for the immutable-field case is apply --recreate <resource> — destroy one resource and recreate it from the manifest in a single act, so the fix for "you set build_pack wrong" stops being a two-verb dance with a window where prod is missing an app. Worth a separate issue, and worth not building until destroy has been used a few times.

## The gap, in cast's own words `apply` fails closed on an immutable-field change, and tells you what to do about it (`apply.ts:74`): > `cannot update in place: <kind> <name> field(s) <fields> — apply never recreates resources; resolve manually (runbook act)` **There is no runbook, and "resolve manually" is a hand-deletion in the Coolify UI** — unscoped, unconfirmed, and against an instance whose token can see every project on it. Battle-testing the first apply means doing that repeatedly. In practice it means deleting the wrong project sometimes, which is how this issue got filed. The same gap shows up in three other places: - **A half-applied first run.** A create that 422s partway leaves a project and an environment behind, and no way back to zero. - **Immutable fields.** `build_pack`, `type`, `version`, and **placement** (`destination_uuid`) are create-time. Getting one wrong means delete + recreate — the only path cast offers is "go do it by hand". - **The DR drill.** *"Rebuild the control plane from state"* has never been rehearsed partly because there is no cheap reset. A teardown verb makes the drill repeatable instead of a one-shot on a throwaway box. ## What it must NOT be **Not an instance wipe.** Servers here are multi-project by design — Coolify gates servers by team, so a box per project is not affordable, and projects share a box with a Docker network each. An instance-scoped destroy on such a box is one wrong `--instance` away from deleting somebody else's production. The legacy box in this fleet hosts two third-party client sites. There is no use for instance-wide destruction that project-scoping does not cover. **Not a flag on `apply`.** *apply never deletes* is the invariant that makes it safe to run on a schedule — orphan env vars are reported, never removed; orphan resources are counted, never touched. A `--prune` turns one stale manifest plus one routine apply into a wiped production. Separate verb, separate gates, or nothing. ## Proposed shape ```sh cast destroy <org>/<repo> --env <env> [--instance <name>] [--with-project] ``` **Manifest-scoped.** It deletes only the resources the manifest declares, in that project and that environment. Resources it finds but did not declare are **reported and left standing** — loudly, because that report is also how you discover something was created outside cast. **Reverse dependency order** — applications, then services, then databases. **Plan, then typed confirmation of the environment name** — the same ceremony `capture` already uses. Names and kinds, never values. **Refuses, rather than no-ops:** - an absent project → refuse and name what *is* there (the D-237 family: an absent target must never read as a clean empty plan, and that is doubly true for a verb whose plan is "delete nothing") - a `COOLIFY_READ_ONLY` instance → refuse, as `apply`/`smoke`/`server add` already do - `--all` → **refuse always.** This is the one verb that must never iterate a fleet. **A prod interlock that lives in state, not in argv.** A `--yes`-style flag is not a gate; it is a thing you type without reading. Put it where a human has to commit it: ```yaml environments: prod: destroy_allowed: true # absent = destroy refuses. Removed at cutover, forever. ``` Same reasoning as `forbidden_var_patterns` living in private state rather than the product manifest: *a change on one side must not be able to lower its own guard.* It is `true` on prod today, while the box is empty and being battle-tested. The cutover checklist deletes the line the moment prod carries real data — and from then on destroying prod requires an operator to edit state, commit it, and merge it. That is the correct amount of friction for a verb that ends companies. **Say what is being destroyed, in terms of what it costs.** A database line should carry whether it has a backup schedule and when the last backup landed — the difference between "recreate this" and "this is unrecoverable" is exactly the thing the operator needs at the confirmation prompt and cannot see in a UUID. ## Not in scope, but adjacent Once `destroy` exists, the natural follow-on for the immutable-field case is `apply --recreate <resource>` — destroy one resource and recreate it from the manifest in a single act, so the fix for "you set `build_pack` wrong" stops being a two-verb dance with a window where prod is missing an app. Worth a separate issue, and worth *not* building until `destroy` has been used a few times.
Sign in to join this conversation.
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#43
No description provided.