feat(destroy): cast destroy — a scoped, state-gated teardown verb (#43) #59
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#59
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/destroy"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The gap
applyfails 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 destroyis that act, scoped and gated.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 insrc/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 onapply(apply never deletesis what makes apply safe to run on a schedule;apply.tsanddiff.tsare 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 thatapplyadopted 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:
Every refusal, and how it is enforced
--all, always--allis 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_ONLYinstanceassertWritable(instance, "destroy")— the same assertapply/smoke/server addtake, same exit coderefuses a read-only instancerenderAbsentDestroyTarget— destroy's own copy of therenderAbsentTargetshape, because that one ends by offering--project/--environmentand 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 thererenderNothingDeclaredHere— the same lie one level deeper (cf.capture'srenderAbsentResources). Names what is there.refuses when the manifest declares nothing this environment holdsdestroy_allowedabsent or falseenvironments.<env>.destroy_allowed: trueinenvironments.yaml(new field inBindingsSchema, next toforbidden_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)confirmDestroy—confirmCapture's ceremony, factored intoconfirmTypedName(capture's behavior and message unchanged)aborts on anything but the environment's own name, typed(y,yes, a different env, EOF)--with-projectwhile anything undeclared is in the way400 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 resourcesThe interlock lives in state, not in argv. A
--yesflag 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_allowedis a line a human edits, commits and merges — in the private state repo, for the same reasonforbidden_var_patternslives there: a change on one side must not be able to lower its own guard. It istruewhile 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/coolifyv4.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 indocs/semantics.md→ Teardown.DELETE /applications|databases|services/{uuid}takes four query parameters and all four default totrue($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 -fper persistent storage (docker compose down -vfor 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— literallydocker 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. Leftfalseit 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 dispatchesCleanupDockeragainst 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
DeleteResourceJoband answers200 {"message":"…deletion request queued."}. A 2xx means accepted, not gone — so--with-projectpollsGET /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, bothisEmpty()). cast refuses first, before the prompt.Backups:
GET /databases/{uuid}/backupsReturns 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}/executionsroute exists and is not needed. The vendored OpenAPI documents this response as the literal string "Content is very complex. Will be implemented later.", soreadBackupStateparses the source's shape (plus two common envelopes) and refuses to guess: an unrecognized shape or an erroring route printsbackup schedule: UNKNOWNwith the reason and is treated as unrecoverable. It never rounds down toNONE. PR #51 is settling this route's live response shape in parallel — if it lands a different envelope,readBackupStateis the single place that has to learn about it, and until then it degrades tounknownrather 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.DESTROY_ORDERis the create order backwards;planDestroysorts by it), kind+name matching (anapplicationnamedpostgresis not thedatabasenamedpostgres), undeclared resources never become targetsreadBackupState: newest execution wins regardless of array order;[]→ NONE; unreadable shapes → UNKNOWN, never NONE; a schedule that has never run is reported as never having runexecuteDestroy: 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 emptydelete_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
--pathis supported (the issue's shape did not list it). Every other verb has it,refusesPathInProdalready 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_ORDERis local tosrc/destroy.ts. PR #45 owns the forward create-order inapply.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.tsanddiff.tsare not touched.confirmCapture's behavior and prompt are unchanged (its body now calls the sharedconfirmTypedName).apply --recreateis deliberately NOT built — the issue defers it, and it is worth not building until destroy has been used a few times.assertWritablethrows, exactly as it does forapply/smoke/server add. Left consistent with the existing verbs rather than special-cased here.Closes #43.