`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.
474 lines
23 KiB
TypeScript
474 lines
23 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
// The team an environment's token MUST belong to. Give `id` (the true auth
|
|
// identity — names can be renamed or duplicated), `name` (human-readable,
|
|
// greppable), or both; both are checked when both are given. `cast team`
|
|
// prints the current token's id and name so this can be filled in.
|
|
const TeamSchema = z
|
|
.object({
|
|
// Non-negative, NOT positive: team **0 is the Root Team** — the one the
|
|
// first user of an instance gets (`if ($user->id === 0) { $team['id'] = 0;
|
|
// $team['name'] = 'Root Team'; }`, app/Models/User.php @ v4.1.2). On a
|
|
// single-admin Coolify that is the team everything lives in, so rejecting
|
|
// 0 would make the id check — the strong half of the assert — unusable on
|
|
// exactly the topology that most needs it.
|
|
id: z.number().int().nonnegative().optional(),
|
|
name: z.string().min(1).optional(),
|
|
})
|
|
.strict()
|
|
.refine((t) => t.id !== undefined || t.name !== undefined, {
|
|
message: "team must give at least one of `id` or `name`",
|
|
});
|
|
|
|
// State that belongs to ONE project inside ONE environment — not to the
|
|
// environment as a whole.
|
|
//
|
|
// The environment block above it is the wrong scope for any of this, and was
|
|
// always going to be: it says `server:`, and a server is exactly the thing two
|
|
// projects can share. Everything here is keyed the way `github_apps` is (see
|
|
// githubAppNameFor) — by the repo, full `<org>/<repo>` slug first — because the
|
|
// repo is what identifies a project to US. The Coolify project NAME is theirs,
|
|
// it is what someone typed into a UI, and `--project` exists precisely because
|
|
// it does not have to match.
|
|
const ProjectBindingSchema = z
|
|
.object({
|
|
// The Docker network this project's resources are created on — a raw UUID,
|
|
// read out of the Coolify UI, exactly like `s3_destination`.
|
|
//
|
|
// A UUID and not a name, deliberately, even though `server:` right above is
|
|
// a name: Coolify 4.1.2 has NO destinations API at all (zero routes in
|
|
// routes/api.php @ v4.1.2), so unlike a server name, cast cannot resolve a
|
|
// destination name to anything. A key called `destination` would invite one.
|
|
// See reference/README.md.
|
|
//
|
|
// Optional, and absent means "whatever the server's only destination is" —
|
|
// which is correct until the server has two, and is why this went unnoticed:
|
|
// Coolify picks `$destinations->first()` when a server has exactly one.
|
|
destination_uuid: z.string().optional(),
|
|
// The app `cast smoke` writes its canary env vars to. Project-scoped
|
|
// because it names one project's application: `core` is incubator's compose
|
|
// app, and the day a second project deploys into this environment, an
|
|
// environment-scoped (let alone the state-file-scoped one it replaces)
|
|
// `smoke_target: core` is simply wrong.
|
|
//
|
|
// It is declared here AND resolved here (#29): `smoke` looks the name up in
|
|
// this project, in this environment, and refuses when it is not there. A
|
|
// bare app name is unique nowhere else — the instance-wide lookup it used to
|
|
// do could pick prod's `core` while smoking staging.
|
|
smoke_target: z.string().optional(),
|
|
})
|
|
.strict();
|
|
|
|
export type ProjectBinding = z.infer<typeof ProjectBindingSchema>;
|
|
|
|
// One project in the registry — the top-level `projects:` block, which is the
|
|
// list of what EXISTS. Nothing else in this file says that: `environments:`
|
|
// says where things are deployed to, `environments.<env>.projects.<repo>` says
|
|
// how one project is placed once you already know it is there, and
|
|
// `github_apps` says how to clone one you have already named. "Every project"
|
|
// was, until this block, a thing the operator remembered.
|
|
//
|
|
// Two things need the list, and neither can be built without it: fleet
|
|
// operations (`cast diff --all`, #26 — iterating "every project in this
|
|
// environment") and rebuild-from-state (#27 — a Coolify restored from the state
|
|
// repo, which cannot even be attempted without knowing what was on it).
|
|
const RegisteredProjectSchema = z
|
|
.object({
|
|
// OUR environment names — the values `--env` takes, the keys of the
|
|
// `environments:` block above — never Coolify's. The distinction is the same
|
|
// one `--env` vs `--environment` draws everywhere else in cast.
|
|
//
|
|
// Non-empty: a project registered into no environment is not a registration,
|
|
// it is a line of YAML that reads like one. It would be skipped by every
|
|
// fleet run silently.
|
|
environments: z.array(z.string().min(1)).nonempty(),
|
|
})
|
|
.strict();
|
|
|
|
export type RegisteredProject = z.infer<typeof RegisteredProjectSchema>;
|
|
|
|
const BindingsSchema = z
|
|
.object({
|
|
environments: z.record(
|
|
z
|
|
.object({
|
|
server: z.string(),
|
|
// REQUIRED, and deliberately so: this is what makes the team assert
|
|
// fail-closed (see team.ts). An environment with no declared team
|
|
// is an environment cast cannot verify it is pointed at — and an
|
|
// unverifiable target is exactly the silent-duplicate-into-the-
|
|
// wrong-team failure this binding exists to prevent. No team, no
|
|
// apply. Today one state dir holds one token (.coolify.env), so
|
|
// every environment in it normally names the same team; declaring
|
|
// it per environment keeps each one's expectation explicit and
|
|
// survives a future split into per-environment tokens.
|
|
team: TeamSchema,
|
|
// The named Coolify instance this environment lives on
|
|
// (<state>/.coolify/<name>.env). Optional: with no binding and no
|
|
// --instance, cast reads <state>/.coolify.env exactly as it always
|
|
// has. Binding it here is what lets `--env prod` select the right
|
|
// control plane with no flag and no file edit — the connection
|
|
// target stops being implicit in a file's current contents.
|
|
// An explicit --instance still wins, so a one-off read against a
|
|
// legacy box needs no change to this file either.
|
|
instance: z.string().optional(),
|
|
// The age recipient (public key) this environment's secret store is
|
|
// encrypted TO. Only `capture` needs it — decryption resolves an
|
|
// identity per keyFileFor, and the state repo deliberately holds
|
|
// ciphertext but never the identity that opens it. This is the
|
|
// public half, so it is safe to commit here next to the bindings.
|
|
age_recipient: z.string().optional(),
|
|
s3_destination: z.string().optional(),
|
|
// Var-name patterns this environment refuses outright (see
|
|
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
|
|
// whatever family of flags enables destructive tooling.
|
|
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
|
|
// server hosts one project needs none of it.
|
|
projects: z.record(ProjectBindingSchema).optional(),
|
|
})
|
|
.strict(),
|
|
),
|
|
// THE REGISTRY: which projects exist at all, keyed by the full `<org>/<repo>`
|
|
// slug. The key IS the repo — there is no `repo:` field inside, because a
|
|
// second place to write the same string is a second place for it to be
|
|
// wrong.
|
|
//
|
|
// Full slug REQUIRED, with no bare-`<repo>` fallback — the one place in this
|
|
// file where that fallback does not exist. `github_apps` and
|
|
// `environments.<env>.projects` carry one because they predate the lesson
|
|
// (#12) and there are state files in the wild keyed the old way; this block
|
|
// is new, has no such files, and so gets to be right from the start. A bare
|
|
// `<repo>` is unique only *within* an org, which is precisely why it is not
|
|
// a key.
|
|
//
|
|
// Optional: a state file written before the registry existed keeps loading
|
|
// untouched, and `projectsIn` answers `[]` for it.
|
|
projects: z.record(RegisteredProjectSchema).optional(),
|
|
// Keyed by the repo the App clones for. Prefer the FULL `<org>/<repo>`
|
|
// slug; a bare `<repo>` key still resolves (see githubAppNameFor) so
|
|
// existing state files keep working.
|
|
github_apps: z.record(z.string()),
|
|
// GONE — nothing reads this any more (#29). It is still DECLARED here, and
|
|
// refused below with a message, precisely because it is gone: this schema is
|
|
// .strict(), so deleting the field outright would make an unmigrated state
|
|
// file fail with a raw zod "unrecognized key" — and loadBindings runs for
|
|
// EVERY verb, so `diff`, `apply`, `capture` and `inventory` would all die on
|
|
// a key none of them ever read, mid-migration, with a message about nothing.
|
|
// A key that has to be removed by hand gets a sentence saying how.
|
|
smoke_target: z.string().optional(),
|
|
})
|
|
.strict()
|
|
// Every check here defends one failure, from two ends: state that a command
|
|
// will silently fail to act on. A registry that lies makes a fleet run skip a
|
|
// project — and a skipped project prints exactly what a clean one prints,
|
|
// nothing. A removed key that is still present makes `smoke` look like it has
|
|
// a target when nothing reads it. Silence is the one report that must never be
|
|
// ambiguous, so these are parse-time errors (every verb loads bindings, so
|
|
// every verb refuses) rather than warnings some command might print.
|
|
.superRefine((bindings, ctx) => {
|
|
// GONE, not merely deprecated (#29) — see the field's note above. Reported
|
|
// first, and without returning: a file may well carry both this and a
|
|
// registry that needs fixing, and the operator should learn about both in
|
|
// one run rather than one per run.
|
|
if (bindings.smoke_target !== undefined) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["smoke_target"],
|
|
// The key could never be fixed, only carried: `smoke` now resolves its
|
|
// target inside the project and environment the target was declared
|
|
// under, and a key scoped to the whole state file has no project to
|
|
// scope to. Carrying it meant keeping the instance-wide name lookup
|
|
// alive for exactly the invocation that most needed it dead —
|
|
// `cast smoke --env prod`.
|
|
message: [
|
|
"the top-level `smoke_target` key is no longer read (#29)",
|
|
"",
|
|
` found: smoke_target: ${bindings.smoke_target} (at the top level of this file)`,
|
|
"",
|
|
"It named ONE project's application from a key scoped to the whole state file,",
|
|
"so it could not tell two projects apart — or even prod's app from staging's.",
|
|
"`cast smoke` now resolves that name inside the project and environment it was",
|
|
"declared under, and this key names no project to resolve it in.",
|
|
"",
|
|
"Move it under the project it belongs to, and pass that repo to `cast smoke`:",
|
|
"",
|
|
" environments:",
|
|
" <env>:",
|
|
" projects:",
|
|
" <org>/<repo>:",
|
|
` smoke_target: ${bindings.smoke_target}`,
|
|
"",
|
|
" cast smoke <org>/<repo> --env <env>",
|
|
].join("\n"),
|
|
});
|
|
}
|
|
|
|
const registry = bindings.projects;
|
|
if (!registry) return;
|
|
|
|
const knownEnvs = Object.keys(bindings.environments);
|
|
const knownEnvList = knownEnvs.join(", ") || "(none)";
|
|
|
|
for (const [slug, project] of Object.entries(registry)) {
|
|
// A key with no `/` is not a repo. See the schema note above: no fallback.
|
|
if (!slug.includes("/")) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["projects", slug],
|
|
message: [
|
|
`projects["${slug}"] is not a repo — a registry key has no meaning without its org`,
|
|
"",
|
|
` found: projects["${slug}"]`,
|
|
" wanted: a full <org>/<repo> slug",
|
|
"",
|
|
"A bare <repo> is unique only *within* an org: heavy-duty/incubator and",
|
|
"acme/incubator collapse onto one entry, and the registry then claims one",
|
|
"project where there are two. Unlike github_apps, this block is new and has",
|
|
"no legacy state files to support, so there is no bare-<repo> fallback.",
|
|
"",
|
|
" projects:",
|
|
` <org>/${slug}:`,
|
|
` environments: [${project.environments.join(", ")}]`,
|
|
].join("\n"),
|
|
});
|
|
}
|
|
|
|
// Every environment named here must be one that actually exists. A typo
|
|
// makes the project real but its environment imaginary — so `--all` visits
|
|
// nothing for it, reports nothing about it, and exits clean.
|
|
for (const envName of project.environments) {
|
|
if (!(envName in bindings.environments)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["projects", slug, "environments"],
|
|
message: [
|
|
`projects["${slug}"] is registered in environment "${envName}", which does not exist`,
|
|
"",
|
|
` registered: projects["${slug}"].environments → ${project.environments.join(", ")}`,
|
|
` known envs: ${knownEnvList}`,
|
|
"",
|
|
"An environment nothing defines is one that no command can visit: a fleet",
|
|
"run would skip this project, and a silently skipped project reads exactly",
|
|
"like a clean one. Fix the name, or declare the environment:",
|
|
"",
|
|
" environments:",
|
|
` ${envName}:`,
|
|
" server: <server>",
|
|
" team: { id: <id>, name: <name> }",
|
|
].join("\n"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// The other direction, and the one that rots quietly: a project carrying
|
|
// per-environment state (destination_uuid, smoke_target — #21) in an
|
|
// environment the registry does not register it for. That state is then real
|
|
// enough to be used by a direct `cast apply <repo> --env <env>` and invisible
|
|
// to every fleet run — the two blocks describing two different fleets.
|
|
//
|
|
// Only checked when `projects:` is present, so state files written before the
|
|
// registry keep loading exactly as they did.
|
|
for (const [envName, env] of Object.entries(bindings.environments)) {
|
|
for (const key of Object.keys(env.projects ?? {})) {
|
|
const entry = registry[key];
|
|
if (entry?.environments.includes(envName)) continue;
|
|
|
|
// The likeliest cause, worth saying out loud: a legacy bare-<repo> key
|
|
// (which projectBindingFor still resolves) under a registry that is
|
|
// correctly keyed by slug. The fix is a rename, not a registration.
|
|
const slugFor = key.includes("/")
|
|
? undefined
|
|
: Object.keys(registry).find((s) => s.endsWith(`/${key}`));
|
|
|
|
const cause = slugFor
|
|
? [
|
|
` registry has: projects["${slugFor}"]`,
|
|
"",
|
|
"The binding uses the legacy bare-<repo> key. The registry is keyed by the",
|
|
`full slug, so rename it to match — environments.${envName}.projects["${slugFor}"].`,
|
|
]
|
|
: entry
|
|
? [
|
|
` registered for: ${entry.environments.join(", ")}`,
|
|
"",
|
|
"Register the project in this environment, or drop the binding — state that",
|
|
"no fleet run will ever visit is state that stops being true without anyone",
|
|
"finding out:",
|
|
"",
|
|
" projects:",
|
|
` ${key}:`,
|
|
` environments: [${[...entry.environments, envName].join(", ")}]`,
|
|
]
|
|
: [
|
|
` registry has: ${Object.keys(registry).join(", ") || "(nothing)"}`,
|
|
"",
|
|
"The project is not in the registry at all, so no fleet run will ever visit",
|
|
"it — while this binding says it is deployed here. Register it, or drop the",
|
|
"binding:",
|
|
"",
|
|
" projects:",
|
|
` ${key}:`,
|
|
` environments: [${envName}]`,
|
|
];
|
|
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["environments", envName, "projects", key],
|
|
message: [
|
|
`environments.${envName}.projects["${key}"] is bound in an environment the registry does not register it for`,
|
|
"",
|
|
` bound at: environments.${envName}.projects["${key}"]`,
|
|
...cause,
|
|
].join("\n"),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export type Bindings = z.infer<typeof BindingsSchema>;
|
|
|
|
// Resolve the Coolify GitHub App name for a repo, full slug first.
|
|
//
|
|
// The short name alone is not a key: `<repo>` is only unique *within* an org,
|
|
// so `heavy-duty/incubator` and `acme/incubator` collapse onto one entry and
|
|
// whichever App is bound there gets used to clone BOTH — silently, because a
|
|
// wrong-but-existing App resolves to a real uuid and the create succeeds. The
|
|
// full slug is the thing that actually identifies a repo, so it wins.
|
|
//
|
|
// The bare-`<repo>` fallback is kept deliberately: it is what every state file
|
|
// written before this used, and dropping it would break them for no gain. A
|
|
// short key is unambiguous right up until a second org shows up, which is
|
|
// precisely when the full-slug key it falls back from starts winning instead.
|
|
export function githubAppNameFor(bindings: Bindings, orgRepo: string): string {
|
|
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
|
|
const name = bindings.github_apps[orgRepo] ?? bindings.github_apps[repoShort];
|
|
if (!name) {
|
|
throw new Error(
|
|
[
|
|
`no GitHub App bound for ${orgRepo}`,
|
|
"",
|
|
` looked for: github_apps["${orgRepo}"], then github_apps["${repoShort}"]`,
|
|
` bound repos: ${Object.keys(bindings.github_apps).join(", ") || "(none)"}`,
|
|
"",
|
|
"Add it to environments.yaml, keyed by the full slug:",
|
|
"",
|
|
" github_apps:",
|
|
` ${orgRepo}: <the App's name in Coolify>`,
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// The project-scoped bindings for one repo in one environment, or undefined if
|
|
// the environment declares none. Same full-slug-then-bare-repo lookup as
|
|
// githubAppNameFor, for the same reason — see the note there.
|
|
//
|
|
// Absence is NOT an error: `projects:` is optional, and an environment with a
|
|
// single project on a single-destination server has nothing to say here. The
|
|
// callers that genuinely need a value (smoke) say so themselves.
|
|
export function projectBindingFor(
|
|
bindings: Bindings,
|
|
envName: string,
|
|
orgRepo: string,
|
|
): ProjectBinding | undefined {
|
|
const projects = bindings.environments[envName]?.projects;
|
|
if (!projects) return undefined;
|
|
const repoShort = orgRepo.split("/")[1] ?? orgRepo;
|
|
return projects[orgRepo] ?? projects[repoShort];
|
|
}
|
|
|
|
// The app `cast smoke` targets, in ONE project of ONE environment — the only
|
|
// scope in which a bare application name is a coordinate at all. There is no
|
|
// fallback and deliberately none: a name that cannot say which project and
|
|
// which environment it belongs to does not identify an application, and `smoke`
|
|
// writes to whatever it identifies (#29).
|
|
//
|
|
// `orgRepo` is required for the same reason: it is the project. Absence is not
|
|
// an error here — an environment may simply declare no smoke target — but the
|
|
// caller has to say so itself, and `smoke` does.
|
|
export function smokeTargetFor(
|
|
bindings: Bindings,
|
|
envName: string,
|
|
orgRepo: string,
|
|
): string | undefined {
|
|
return projectBindingFor(bindings, envName, orgRepo)?.smoke_target;
|
|
}
|
|
|
|
// The `<org>/<repo>` slugs registered for one environment — the list a fleet
|
|
// operation iterates (`cast diff --all`, #26) and a rebuild reads (#27).
|
|
//
|
|
// SORTED, deliberately: the order of keys in a YAML file is an accident of who
|
|
// typed what when, and a fleet run's output — which a human reads top to bottom,
|
|
// and CI diffs — must not reshuffle because someone appended a project. The
|
|
// registry is a set; this returns it as one.
|
|
//
|
|
// `[]` when there is no registry, which is every state file written before this
|
|
// block existed. That is the honest answer to "which projects are registered
|
|
// here" when nothing is registered anywhere — and it is emphatically NOT a
|
|
// licence to do nothing with it: the fleet verbs REFUSE on an empty list (#26,
|
|
// renderEmptyRegistry). An earlier draft of this comment guessed that `[]` would
|
|
// make `--all` over an unmigrated state file "a clean no-op rather than a crash",
|
|
// which is precisely backwards — "0 projects, clean" is a clean fleet's report
|
|
// printed over a fleet nobody looked at. There is no honest no-op here; there is
|
|
// only a refusal that says what it looked for.
|
|
export function projectsIn(bindings: Bindings, envName: string): string[] {
|
|
const registry = bindings.projects;
|
|
if (!registry) return [];
|
|
return Object.entries(registry)
|
|
.filter(([, project]) => project.environments.includes(envName))
|
|
.map(([slug]) => slug)
|
|
.sort();
|
|
}
|
|
|
|
export function loadBindings(
|
|
path: string,
|
|
opts: { overrideText?: string } = {},
|
|
): Bindings {
|
|
const text = opts.overrideText ?? readFileSync(path, "utf8");
|
|
const result = BindingsSchema.safeParse(parse(text));
|
|
if (!result.success) {
|
|
// Zod's own `.message` is the entire issue array as JSON — which renders the
|
|
// refusals above as one long line of `\n` escapes, i.e. throws away the part
|
|
// of them that was worth writing. That matters most for the ones that are
|
|
// not typos at all but migrations (the removed `smoke_target`), where the
|
|
// message IS the instruction. Render the issues instead.
|
|
const detail = result.error.issues
|
|
.map((issue) => {
|
|
// A multi-line message is one WE wrote: it already names the path, the
|
|
// cause, and the YAML to write. A one-line message is zod's ("Required"),
|
|
// and is useless without the path it happened at.
|
|
if (issue.message.includes("\n")) return issue.message;
|
|
const where = issue.path.map(String).join(".");
|
|
return where ? `${where}: ${issue.message}` : issue.message;
|
|
})
|
|
.join("\n\n");
|
|
throw new Error(`invalid bindings ${path}:\n\n${detail}`);
|
|
}
|
|
return result.data;
|
|
}
|