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:
parent
d5d984f631
commit
6849d29f0e
7 changed files with 1889 additions and 5 deletions
98
README.md
98
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 —
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
261
src/cli.ts
261
src/cli.ts
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
600
src/destroy.ts
Normal 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
748
test/destroy.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue