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.
2026-07-14 22:33:46 +00:00
|
|
|
import { spawn } from "node:child_process";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
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.
2026-07-14 22:33:46 +00:00
|
|
|
import { createServer } from "node:http";
|
|
|
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
|
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";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
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.
2026-07-14 22:33:46 +00:00
|
|
|
|
|
|
|
|
// 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 } = {},
|
|
|
|
|
) {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const checkout = tmp("cast-co-");
|
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.
2026-07-14 22:33:46 +00:00
|
|
|
mkdirSync(join(checkout, ".infra"), { recursive: true });
|
|
|
|
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
|
|
|
|
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const state = tmp("cast-state-");
|
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.
2026-07-14 22:33:46 +00:00
|
|
|
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"');
|
|
|
|
|
});
|
|
|
|
|
});
|