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;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44)
Coolify enforces domain uniqueness across the whole instance; cast plans
inside one project + one environment. So apply could produce a plan that
was internally consistent, correct against everything cast can observe,
and still be refused — by a resource in a project cast never queries,
arriving as a raw 409 mid-apply, after the project and the environment
had already been created.
- Pre-flight the create plan: before the first write (project and
environment are created lazily, by the first create), check the domains
the plan is about to claim against GET /applications. A conflict is now
a refusal that costs nothing, not a half-applied run. One GET, and only
on a plan that creates an application with a domain — a first apply.
Covers both live shapes: fqdn, and per-service docker_compose_domains.
- Translate the 409 when one gets through anyway (a conflict with a
service fqdn or the instance fqdn is not visible in GET /applications,
so the pre-flight is a strict subset of Coolify's check). Names the
domain, the resource, its uuid — and whether it is outside the applied
project, which is the part the operator cannot get from Coolify.
- Never send force_domain_override=true. Coolify suggests it in the error
text; two resources on one domain is a routing coin-flip, and Coolify
says so in the same response.
Closes #44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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`);
|
|
|
|
|
}
|
|
|
|
|
}
|