cast/src/coolify.ts

292 lines
14 KiB
TypeScript
Raw Normal View History

feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
type Json = Record<string, unknown> | unknown[] | null;
feat: assert the token's team before touching Coolify (fail-closed) Coolify API tokens are team-scoped, and a wrong-team token does not error: the API resolves what it cannot see to `null` (getResourceByUuid walks resource → environment → project → team_id and returns null on a mismatch). To cast, `null` is indistinguishable from "this resource does not exist yet" — an invitation to create it. So an apply with a token minted under the wrong team would not fail loudly; it would provision a duplicate set of resources into the wrong team, against whatever server that team owns. Silent, mutating, discovered late. That makes this a correctness bug, not hardening. - environments.yaml carries a required `team:` per environment (id, name, or both). Required is the point: an environment with no declared team is one cast cannot verify it is pointed at. - Every command that reaches a live Coolify (apply, diff, server add, smoke) resolves GET /teams/current — the only endpoint that answers "what team does this token act as?" — and aborts on mismatch before its first READ, not merely its first write: a wrong-team diff reports "everything is absent", which is the very lie an apply would then act on. - server add and smoke take --env for this reason. A server belongs to exactly one team forever (no pivot, no is_system_wide escape hatch), and smoke writes env vars onto a live app. - New read-only `cast team` prints the token's team, so the binding can be filled in without a chicken-and-egg. With --env it also checks the binding: the dry run for "would apply refuse?". Team id 0 is a first-class value, not a falsy absent — it is the Root Team that a single-admin instance keeps everything in (app/Models/User.php). Also records the #4 investigation in docs/semantics.md: GithubApp `is_system_wide` IS the supported way to serve every team — list_github_apps scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps accepts the flag — so per-team App duplication is unnecessary. Corollary: resolving a GitHub App by name is NOT a proxy for being in the right team, which is the second reason the assert has to be explicit. Closes #9 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
// The team a token acts as. Coolify's `Team` model carries more than this
// (description, personal_team, timestamps); cast only ever needs identity.
export type Team = { id: number; name: string };
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
// Thrown by req/reqText on a non-2xx response. `status` lets callers narrow
// handling (e.g. "treat 404 as absent, rethrow everything else") via
// `instanceof HttpError` without parsing the message string; the message
// format itself is unchanged (asserted by coolify.test.ts).
export class HttpError extends Error {
constructor(
method: string,
path: string,
public readonly status: number,
body: string,
) {
super(`${method} ${path}${status}: ${body}`);
this.name = "HttpError";
}
}
export class CoolifyClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly fetchImpl: typeof fetch = fetch,
) {}
private async req(
method: string,
path: string,
body?: unknown,
): Promise<Json> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
throw new HttpError(method, path, res.status, await res.text());
}
return res.status === 204 ? null : ((await res.json()) as Json);
}
private async reqText(method: string, path: string): Promise<string> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
},
});
if (!res.ok) {
throw new HttpError(method, path, res.status, await res.text());
}
return res.text();
}
get = (path: string) => this.req("GET", path);
post = (path: string, body?: unknown) => this.req("POST", path, body);
patch = (path: string, body?: unknown) => this.req("PATCH", path, body);
delete_ = (path: string) => this.req("DELETE", path);
async version(): Promise<string> {
return this.reqText("GET", "/version");
}
feat: assert the token's team before touching Coolify (fail-closed) Coolify API tokens are team-scoped, and a wrong-team token does not error: the API resolves what it cannot see to `null` (getResourceByUuid walks resource → environment → project → team_id and returns null on a mismatch). To cast, `null` is indistinguishable from "this resource does not exist yet" — an invitation to create it. So an apply with a token minted under the wrong team would not fail loudly; it would provision a duplicate set of resources into the wrong team, against whatever server that team owns. Silent, mutating, discovered late. That makes this a correctness bug, not hardening. - environments.yaml carries a required `team:` per environment (id, name, or both). Required is the point: an environment with no declared team is one cast cannot verify it is pointed at. - Every command that reaches a live Coolify (apply, diff, server add, smoke) resolves GET /teams/current — the only endpoint that answers "what team does this token act as?" — and aborts on mismatch before its first READ, not merely its first write: a wrong-team diff reports "everything is absent", which is the very lie an apply would then act on. - server add and smoke take --env for this reason. A server belongs to exactly one team forever (no pivot, no is_system_wide escape hatch), and smoke writes env vars onto a live app. - New read-only `cast team` prints the token's team, so the binding can be filled in without a chicken-and-egg. With --env it also checks the binding: the dry run for "would apply refuse?". Team id 0 is a first-class value, not a falsy absent — it is the Root Team that a single-admin instance keeps everything in (app/Models/User.php). Also records the #4 investigation in docs/semantics.md: GithubApp `is_system_wide` IS the supported way to serve every team — list_github_apps scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps accepts the flag — so per-team App duplication is unnecessary. Corollary: resolving a GitHub App by name is NOT a proxy for being in the right team, which is the second reason the assert has to be explicit. Closes #9 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
// The team this TOKEN acts as — the question every mutation depends on
// (see team.ts for why). GET /teams/current resolves it from the token
// itself, not from a session: TeamController@current_team calls
// getTeamIdFromToken() and 404s if that team is gone
// (coollabsio/coolify v4.1.2). It is the only endpoint that answers it.
async currentTeam(): Promise<Team> {
const raw = (await this.get("/teams/current")) as Record<
string,
unknown
> | null;
const id = raw?.id;
const name = raw?.name;
// A shape we can't read is not "no team" — it's an unknown answer to the
// one question we must not guess at. Fail rather than degrade.
if (typeof id !== "number" || typeof name !== "string") {
throw new Error(
`GET /teams/current returned no usable team identity: ${JSON.stringify(raw)}`,
);
}
return { id, name };
}
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
private async resolve(
kind: string,
listPath: string,
name: string,
): Promise<string> {
const items = (await this.get(listPath)) as Array<{
uuid: string;
name: string;
}>;
const hit = items.find((i) => i.name === name);
if (!hit) throw new Error(`not found in Coolify: ${kind} ${name}`);
return hit.uuid;
}
serverUuid = (name: string) => this.resolve("server", "/servers", name);
githubAppUuid = (name: string) =>
this.resolve("github app", "/github-apps", name);
projectUuid = (name: string) => this.resolve("project", "/projects", name);
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered `inventory` (#19/#20) reconciled a manifest against one project and one environment THAT YOU NAME. But the premise of the verb is that you are looking at a box you did not build — so you do not know those coordinates yet. It was a discovery tool that required you to have already discovered, and the operator went straight back to hand-curling /projects to find out where anything lived. cast inventory --env prod --instance box-b # no repo → sweep Every project, every environment, every resource the token can see. No manifest, no store, no age key, no recipient. With a repo it reconciles exactly as before. Worse than the missing sweep was how the targeted path FAILED. Pointed at a project's `production` environment — auto-created by Coolify, and empty — it reported: on the box, NOT in the manifest (none) 5 difference(s) between the manifest and this box. Every word true; the overall impression ("the box has nothing, the manifest has five things") exactly the D-237 lie cast refuses everywhere else. The resources were alive and serving production the whole time, in an environment named `staging` that nobody had ever swapped. An environment with ZERO resources is far more often the wrong coordinate than an empty one, so it now says so, and names the sweep. The sweep asserts the team first, and that matters more here than anywhere: Coolify scopes what a token can see to its team, so a wrong-team token would sweep an instance and truthfully report that it is empty. Environment enumeration takes two roads — GET /projects/{uuid}/environments, falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has been wrong before, and this is the one path where failing to enumerate is worse than being slow. The stub in the new suite is shaped like the box this came from: three projects (two of them unrelated third-party client sites nobody knew were there), an empty auto-created `production`, and the real system in `staging`. npm run check + build clean; 173 tests passing (was 169). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
// Every project the TOKEN can see. Team-scoped by Coolify itself, which is
// why a sweep still asserts the team first: a wrong-team token sees nothing,
// and "nothing" would render as "this instance is empty".
async projects(): Promise<Array<{ uuid: string; name: string }>> {
return (await this.get("/projects")) as Array<{
uuid: string;
name: string;
}>;
}
2026-07-14 22:30:34 +00:00
// Every application the TOKEN can see, raw — the whole instance, not one
// project. The population a create's domains are checked against, and the
// only way cast can read it (see the domain pre-flight in cli.ts, #44).
//
// Two scopes have to be the same one for a pre-flight to mean anything, and
// they are: ApplicationsController@applications lists
// `Application::ownedByCurrentTeamAPI($teamId)`, and the create-time conflict
// check (bootstrap/helpers/domains.php@checkIfDomainIsAlreadyUsedViaAPI,
// v4.1.2) walks that same set. What it ALSO walks and this does not:
// ServiceApplication fqdns and the instance-level fqdn. So this list is a
// subset of what Coolify checks — which is why the 409 translation stays,
// and is not dead code once the pre-flight exists.
//
// Raw records rather than a narrow type, because the useful fields are not
// documented anywhere cast could import from: the vendored OpenAPI does not
// even list `fqdn` here. The list is serialized by the SAME
// removeSensitiveData() the per-application GET uses (ApplicationsController
// :38, called at :130 and :1980 @ v4.1.2), so it carries every non-sensitive
// column — `fqdn`, `docker_compose_domains`, `build_pack`, `uuid`, `name` —
// and a per-app GET would return byte-for-byte the same fields. Reading them
// is the caller's job (cli.ts@liveApplicationDomains).
async applications(): Promise<Array<Record<string, unknown>>> {
const raw = await this.get("/applications");
return Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
}
feat: inventory sweeps the instance — a discovery verb that needed you to have discovered `inventory` (#19/#20) reconciled a manifest against one project and one environment THAT YOU NAME. But the premise of the verb is that you are looking at a box you did not build — so you do not know those coordinates yet. It was a discovery tool that required you to have already discovered, and the operator went straight back to hand-curling /projects to find out where anything lived. cast inventory --env prod --instance box-b # no repo → sweep Every project, every environment, every resource the token can see. No manifest, no store, no age key, no recipient. With a repo it reconciles exactly as before. Worse than the missing sweep was how the targeted path FAILED. Pointed at a project's `production` environment — auto-created by Coolify, and empty — it reported: on the box, NOT in the manifest (none) 5 difference(s) between the manifest and this box. Every word true; the overall impression ("the box has nothing, the manifest has five things") exactly the D-237 lie cast refuses everywhere else. The resources were alive and serving production the whole time, in an environment named `staging` that nobody had ever swapped. An environment with ZERO resources is far more often the wrong coordinate than an empty one, so it now says so, and names the sweep. The sweep asserts the team first, and that matters more here than anywhere: Coolify scopes what a token can see to its team, so a wrong-team token would sweep an instance and truthfully report that it is empty. Environment enumeration takes two roads — GET /projects/{uuid}/environments, falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has been wrong before, and this is the one path where failing to enumerate is worse than being slow. The stub in the new suite is shaped like the box this came from: three projects (two of them unrelated third-party client sites nobody knew were there), an empty auto-created `production`, and the real system in `staging`. npm run check + build clean; 173 tests passing (was 169). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00
// A project's environment NAMES.
//
// Two roads, because the vendored OpenAPI has been wrong before and this is a
// discovery path — the one place where failing to enumerate is worse than
// being slow. GET /projects/{uuid}/environments is documented; if a given
// instance does not serve it, GET /projects/{uuid} carries the same list as a
// relation on the project (ProjectController@show eager-loads it). Falling
// back beats reporting "no environments" for a project that has several.
async environments(projectUuid: string): Promise<string[]> {
const names = (items: unknown): string[] =>
Array.isArray(items)
? items
.map((e) => (e as { name?: unknown })?.name)
.filter((n): n is string => typeof n === "string")
: [];
try {
const direct = await this.get(`/projects/${projectUuid}/environments`);
const found = names(direct);
if (found.length > 0) return found;
} catch (err) {
if (!(err instanceof HttpError) || err.status !== 404) throw err;
}
const project = (await this.get(`/projects/${projectUuid}`)) as {
environments?: unknown;
} | null;
return names(project?.environments);
}
fix: the first apply against a fresh multi-destination box (#40, #41) Both of these were found by the same run — the genuinely-from-nothing apply that #38 was also hiding in, against a box that shares its server with another project. Neither is a bug in what apply DOES; both are bugs in what it leaves behind and what it says. #40 — cast removes the default environment it made Coolify create. POST /projects hands a new project Coolify's OWN default environment, `production`. #39 taught apply to create the environment its resources actually name, so a project cast creates from nothing now ends up carrying two: ours, holding everything, and an empty `production` that nothing will ever use. That is precisely the shape that makes a box unreadable later, and we have the live example — on the box being migrated away from, `production` is empty and everything runs in `staging`, and "the obvious guess is the wrong one" is a note we had to write down for ourselves. Shipping more of those is not neutrality. This is the only delete cast performs, so it argues for itself against apply-never- deletes: what that rule protects is things cast did not make, and this is a byproduct of cast's own POST /projects seconds earlier, holding nothing and having never held anything. Three conditions, jointly, or nothing is touched — cast created the project in THIS run (never a project someone built by hand), the environment is EMPTY (asked of Coolify via the details route, the only one that eager-loads resources — not inferred from the first condition), and its name is NOT ours (an --environment production keeps its production, since that is where everything is about to live). Best-effort: a delete that fails is reported and never fails an apply that worked. #41 — the multi-destination 400 says what to do, and the plan says what it assumed. A create against a server with more than one destination that names none is rejected with "Server has multiple destinations and you do not set destination_uuid." — a message that names neither the remedy nor the file it goes in, arriving at the FIRST create, after apply has already made the project and the environment. cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations API at all, and GET /servers/{uuid} does not carry them either, so a server's destination COUNT is unknowable until a create has been attempted. The diagnosis is what is fixable. The 400 is now answered with the failing resource, the server by the name the operator wrote (not its UUID), the exact path the UUID goes in (environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning — placement is repaired by delete + recreate, never by a later apply — and Coolify's own words kept verbatim, so the next person's search still works. And the assumption behind an undeclared destination is now on screen at the moment it is made: `placement: server's default destination (none declared)`. This reverses a judgment cast held explicitly ("a line on every diff that says nothing is how a report stops being read" — the test it replaces). The line does not say nothing; it says which network the next create lands on. It stays on a clean run that creates nothing, too, because the trap is set for projects that are already built: the day their server gains a second destination, every one of them that declared no destination stops being able to create, and nothing will have warned them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
// Does this environment hold anything at all?
//
// The LIST route above cannot answer it: an `Environment` carries id, name,
// project_id, description, timestamps — no relations, so an environment with
// five applications in it looks exactly like an empty one. This route is the
// one that eager-loads them (ProjectController@environment_details, v4.1.2:
// applications, postgresqls, redis, mongodbs, mysqls, mariadbs, services).
//
// The question is asked of the SHAPE rather than of those seven names: any
// non-empty array in the response is a resource list, because everything else
// there is a scalar. Naming the seven instead would mean a Coolify that grows
// an eighth database type could answer "empty" about an environment holding
// one — and this answer is the guard on a delete.
async environmentIsEmpty(
projectUuid: string,
envName: string,
): Promise<boolean> {
const env = (await this.get(
`/projects/${projectUuid}/${encodeURIComponent(envName)}`,
)) as Record<string, unknown> | null;
// Not "empty" — unreadable. The caller must not delete on this answer.
if (!env) return false;
return !Object.values(env).some((v) => Array.isArray(v) && v.length > 0);
}
async deleteEnvironment(projectUuid: string, envName: string): Promise<void> {
await this.delete_(
`/projects/${projectUuid}/environments/${encodeURIComponent(envName)}`,
);
}
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
// 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}`,
);
}
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
async deploy(uuid: string): Promise<void> {
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
}
async restart(uuid: string): Promise<void> {
await this.post(`/services/${encodeURIComponent(uuid)}/restart`);
}
}