cast/src/coolify.ts

214 lines
8.4 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: 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`);
}
}