cast/src/coolify.ts

441 lines
21 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";
}
}
feat: diff and apply a database's backup schedule (#51) Backup schedules were write-only, filed under "known limitations" on the claim that "live Coolify state doesn't expose it back". The parenthesis was load-bearing and false: a schedule is not on the database's own GET, but it was never meant to be — it has its own route, GET /databases/{uuid}/backups, which cast had been POSTing to all along and had simply never read. The cost was exact. A database created before its `backup:` block was declared never got one (apply set the schedule only inside the create branch); a schedule deleted in the UI was invisible; and the `--full` diff that gates a production cutover passed with an unbacked-up production database. Shape settled from the source rather than the vendored spec, which documents the body as "Content is very complex. Will be implemented later.": DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns per $fillable (uuid, enabled, frequency, database_backup_retention_amount_locally). `frequency` round-trips verbatim: the controller validates it and stores $request->only(...) unchanged, with no mutator on the model. The "diffing it would flag spurious drift" fear was a guess about a read nobody had performed. - `backup` becomes a diffed field like any other (resolve.ts), replacing the side channel that carried it around the diff. - The live side reads the route (coolify.ts, fetchLive), and apply sets the schedule on UPDATE as well as create — POST or PATCH, decided by a read. - A disabled schedule is a row that backs nothing up: neither clean nor absent. cast diffs it and re-enables it. Degrades honestly, since no live box was probed: an unreachable or unrecognized response can only ever produce "declared, NOT compared — verify in the Coolify UI", never invented drift and never a clean bill on an unread database. On the write side the same failure raises rather than guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
// A database's scheduled backup, as cast is able to read it back.
//
// `retention` is Coolify's `database_backup_retention_amount_locally` — the
// same field cast has always POSTed on create. `enabled` is carried because a
// DISABLED schedule is a row that exists and backs nothing up: reporting that
// database as backed-up is the one lie this whole path exists to prevent.
export type LiveBackup = {
uuid: string;
frequency: string;
retention: number;
enabled: boolean;
fix(draft): read backup schedules and emit backup blocks (#75) --emit-draft still told every reader that backup schedules "are not exposed by Coolify's API" — the exact pre-#51 claim that issue disproved: GET /databases/{uuid}/backups is a route, and diff/apply have read it on every run since. The draft path was never brought along, so it warned instead of reading, and a rebuild from a draft came up with no backups. Now the draft loop makes the same supplementary per-database GET (databaseBackupSchedules) for every DRAFTED database and databaseSpec emits a real backup: { frequency, retention } block for the one shape the manifest can express — a single, enabled schedule. Ungated on purpose: fetchLive's opts.backups gate exists because the read-side sweeps never look at the answer, and the draft is the sweep that does. The read stays sequential (like the existing per-resource env GETs) and a failed read degrades to an UNCAPTURED entry per resource rather than aborting the whole-instance sweep — a draft's reader is a human, not an apply about to write. UNCAPTURED keeps only what the route genuinely cannot answer: - the S3 target: save_s3 now rides on LiveBackup, and a schedule that saves to S3 gets a per-database entry saying the target reads back only as s3_storage_id, an int nothing maps to a storage UUID - a DISABLED schedule (declaring the block would make apply re-enable it) - several schedules where a manifest declares one - an unreadable route (reported, never read as "no backups") The stale NO_API_COVERAGE "backup schedules" row becomes "a backup schedule's S3 target", and semantics.md's draft section now tells the truth about what is captured. Closes #75 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
// Whether the schedule saves to S3 (`save_s3`). Carried for the DRAFT's
// UNCAPTURED report (#75): the schedule itself is now capturable, but its
// TARGET reads back only as `s3_storage_id` — an int no endpoint maps to a
// storage UUID — so "saves to S3" is readable and "saves to WHICH S3" is not,
// and a draft has to say so. diff/apply ignore it (apply asserts save_s3 +
// the environment's s3_destination on every write).
saveS3: boolean;
feat: diff and apply a database's backup schedule (#51) Backup schedules were write-only, filed under "known limitations" on the claim that "live Coolify state doesn't expose it back". The parenthesis was load-bearing and false: a schedule is not on the database's own GET, but it was never meant to be — it has its own route, GET /databases/{uuid}/backups, which cast had been POSTing to all along and had simply never read. The cost was exact. A database created before its `backup:` block was declared never got one (apply set the schedule only inside the create branch); a schedule deleted in the UI was invisible; and the `--full` diff that gates a production cutover passed with an unbacked-up production database. Shape settled from the source rather than the vendored spec, which documents the body as "Content is very complex. Will be implemented later.": DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns per $fillable (uuid, enabled, frequency, database_backup_retention_amount_locally). `frequency` round-trips verbatim: the controller validates it and stores $request->only(...) unchanged, with no mutator on the model. The "diffing it would flag spurious drift" fear was a guess about a read nobody had performed. - `backup` becomes a diffed field like any other (resolve.ts), replacing the side channel that carried it around the diff. - The live side reads the route (coolify.ts, fetchLive), and apply sets the schedule on UPDATE as well as create — POST or PATCH, decided by a read. - A disabled schedule is a row that backs nothing up: neither clean nor absent. cast diffs it and re-enables it. Degrades honestly, since no live box was probed: an unreachable or unrecognized response can only ever produce "declared, NOT compared — verify in the Coolify UI", never invented drift and never a clean bill on an unread database. On the write side the same failure raises rather than guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
};
// The result of trying to read a database's schedules. The two absences are
// NOT the same fact and must never collapse into one another (the LiveLookup
// lesson, one level down):
//
// [] — read cleanly, this database has NO schedule. Trustworthy, and
// therefore real drift if the manifest declares one.
// undefined — NOT READ: transport error, or a body cast does not recognize.
// Says nothing. Must never become "no backups" (which would
// invent drift, and make apply POST a duplicate schedule) nor
// "backed up" (which would pass a cutover on an unbacked-up db).
export type BackupRead = LiveBackup[] | undefined;
// Coolify's int columns arrive as ints, but a tinyint `enabled` has no cast on
// ScheduledDatabaseBackup (v4.1.2 casts() covers only the two float storage
// fields), so it can serialize as 1/0 rather than true/false. Accept both; only
// an explicit falsey value disables. An ABSENT `enabled` is read as enabled —
// Coolify's own create path defaults it to true (DatabasesController, v4.1.2).
function readEnabled(raw: Record<string, unknown>): boolean {
const v = raw.enabled;
if (v === undefined || v === null) return true;
return !(v === false || v === 0 || v === "0");
}
fix(draft): read backup schedules and emit backup blocks (#75) --emit-draft still told every reader that backup schedules "are not exposed by Coolify's API" — the exact pre-#51 claim that issue disproved: GET /databases/{uuid}/backups is a route, and diff/apply have read it on every run since. The draft path was never brought along, so it warned instead of reading, and a rebuild from a draft came up with no backups. Now the draft loop makes the same supplementary per-database GET (databaseBackupSchedules) for every DRAFTED database and databaseSpec emits a real backup: { frequency, retention } block for the one shape the manifest can express — a single, enabled schedule. Ungated on purpose: fetchLive's opts.backups gate exists because the read-side sweeps never look at the answer, and the draft is the sweep that does. The read stays sequential (like the existing per-resource env GETs) and a failed read degrades to an UNCAPTURED entry per resource rather than aborting the whole-instance sweep — a draft's reader is a human, not an apply about to write. UNCAPTURED keeps only what the route genuinely cannot answer: - the S3 target: save_s3 now rides on LiveBackup, and a schedule that saves to S3 gets a per-database entry saying the target reads back only as s3_storage_id, an int nothing maps to a storage UUID - a DISABLED schedule (declaring the block would make apply re-enable it) - several schedules where a manifest declares one - an unreadable route (reported, never read as "no backups") The stale NO_API_COVERAGE "backup schedules" row becomes "a backup schedule's S3 target", and semantics.md's draft section now tells the truth about what is captured. Closes #75 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
// `save_s3` is a tinyint with no cast, exactly like `enabled`, so accept 1/0
// too. Unlike `enabled`, an ABSENT value reads as FALSE: the only consumer is
// the draft's "this backup lands in S3" report, and that claim must never be
// made off a field the row did not carry.
function readSaveS3(raw: Record<string, unknown>): boolean {
const v = raw.save_s3;
return v === true || v === 1 || v === "1";
}
feat: diff and apply a database's backup schedule (#51) Backup schedules were write-only, filed under "known limitations" on the claim that "live Coolify state doesn't expose it back". The parenthesis was load-bearing and false: a schedule is not on the database's own GET, but it was never meant to be — it has its own route, GET /databases/{uuid}/backups, which cast had been POSTing to all along and had simply never read. The cost was exact. A database created before its `backup:` block was declared never got one (apply set the schedule only inside the create branch); a schedule deleted in the UI was invisible; and the `--full` diff that gates a production cutover passed with an unbacked-up production database. Shape settled from the source rather than the vendored spec, which documents the body as "Content is very complex. Will be implemented later.": DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns per $fillable (uuid, enabled, frequency, database_backup_retention_amount_locally). `frequency` round-trips verbatim: the controller validates it and stores $request->only(...) unchanged, with no mutator on the model. The "diffing it would flag spurious drift" fear was a guess about a read nobody had performed. - `backup` becomes a diffed field like any other (resolve.ts), replacing the side channel that carried it around the diff. - The live side reads the route (coolify.ts, fetchLive), and apply sets the schedule on UPDATE as well as create — POST or PATCH, decided by a read. - A disabled schedule is a row that backs nothing up: neither clean nor absent. cast diffs it and re-enables it. Degrades honestly, since no live box was probed: an unreachable or unrecognized response can only ever produce "declared, NOT compared — verify in the Coolify UI", never invented drift and never a clean bill on an unread database. On the write side the same failure raises rather than guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
// Strict on purpose: a value cast cannot read EXACTLY is not coerced into a
// guess, it collapses the whole read to `undefined` (= "not compared", said out
// loud). Silence about a backup is the failure being fixed here; a wrong number
// about one would be worse than the silence.
function readInt(v: unknown): number | undefined {
if (typeof v === "number" && Number.isInteger(v)) return v;
if (typeof v === "string" && /^\d+$/.test(v)) return Number(v);
return undefined;
}
// Parse GET /databases/{uuid}/backups.
//
// The vendored OpenAPI documents this body as "Content is very complex. Will be
// implemented later." — so the shape here comes from the source instead:
// DatabasesController@database_backup_details_uuid (v4.1.2) ends with
//
// $backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)
// ->with('executions')->where('database_id', $database->id)->get();
// return response()->json($backupConfig);
//
// i.e. a raw Eloquent collection — a JSON ARRAY of ScheduledDatabaseBackup rows
// (no API resource, no removeSensitiveData), whose columns are the model's
// $fillable: uuid, enabled, save_s3, frequency,
// database_backup_retention_amount_locally, ... plus an eager-loaded
// `executions` array cast ignores.
//
// `frequency` round-trips VERBATIM: the controller validates it
// (validate_cron_expression, which only returns a bool) and then stores
// $request->only($backupConfigFields) unchanged — there is no mutator on the
// model. So "0 3 * * *" reads back as "0 3 * * *", and the preset words
// (daily, weekly, ...) read back as themselves. That is what makes this field
// diffable at all, and it is the fact the old "spurious drift" fear assumed
// away without checking.
export function parseBackupSchedules(raw: unknown): BackupRead {
// Not an array = not the documented collection. Unknown answer, not "none".
if (!Array.isArray(raw)) return undefined;
const schedules: LiveBackup[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return undefined;
const row = item as Record<string, unknown>;
const uuid = row.uuid;
const frequency = row.frequency;
const retention = readInt(row.database_backup_retention_amount_locally);
// One unreadable row makes the whole read unreadable. A partial list would
// be indistinguishable from a complete one to every caller downstream, and
// the caller most worth protecting is the one asking "is this backed up?".
if (
typeof uuid !== "string" ||
typeof frequency !== "string" ||
retention === undefined
) {
return undefined;
}
schedules.push({
uuid,
frequency,
retention,
enabled: readEnabled(row),
fix(draft): read backup schedules and emit backup blocks (#75) --emit-draft still told every reader that backup schedules "are not exposed by Coolify's API" — the exact pre-#51 claim that issue disproved: GET /databases/{uuid}/backups is a route, and diff/apply have read it on every run since. The draft path was never brought along, so it warned instead of reading, and a rebuild from a draft came up with no backups. Now the draft loop makes the same supplementary per-database GET (databaseBackupSchedules) for every DRAFTED database and databaseSpec emits a real backup: { frequency, retention } block for the one shape the manifest can express — a single, enabled schedule. Ungated on purpose: fetchLive's opts.backups gate exists because the read-side sweeps never look at the answer, and the draft is the sweep that does. The read stays sequential (like the existing per-resource env GETs) and a failed read degrades to an UNCAPTURED entry per resource rather than aborting the whole-instance sweep — a draft's reader is a human, not an apply about to write. UNCAPTURED keeps only what the route genuinely cannot answer: - the S3 target: save_s3 now rides on LiveBackup, and a schedule that saves to S3 gets a per-database entry saying the target reads back only as s3_storage_id, an int nothing maps to a storage UUID - a DISABLED schedule (declaring the block would make apply re-enable it) - several schedules where a manifest declares one - an unreadable route (reported, never read as "no backups") The stale NO_API_COVERAGE "backup schedules" row becomes "a backup schedule's S3 target", and semantics.md's draft section now tells the truth about what is captured. Closes #75 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:25:34 +00:00
saveS3: readSaveS3(row),
feat: diff and apply a database's backup schedule (#51) Backup schedules were write-only, filed under "known limitations" on the claim that "live Coolify state doesn't expose it back". The parenthesis was load-bearing and false: a schedule is not on the database's own GET, but it was never meant to be — it has its own route, GET /databases/{uuid}/backups, which cast had been POSTing to all along and had simply never read. The cost was exact. A database created before its `backup:` block was declared never got one (apply set the schedule only inside the create branch); a schedule deleted in the UI was invisible; and the `--full` diff that gates a production cutover passed with an unbacked-up production database. Shape settled from the source rather than the vendored spec, which documents the body as "Content is very complex. Will be implemented later.": DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns per $fillable (uuid, enabled, frequency, database_backup_retention_amount_locally). `frequency` round-trips verbatim: the controller validates it and stores $request->only(...) unchanged, with no mutator on the model. The "diffing it would flag spurious drift" fear was a guess about a read nobody had performed. - `backup` becomes a diffed field like any other (resolve.ts), replacing the side channel that carried it around the diff. - The live side reads the route (coolify.ts, fetchLive), and apply sets the schedule on UPDATE as well as create — POST or PATCH, decided by a read. - A disabled schedule is a row that backs nothing up: neither clean nor absent. cast diffs it and re-enables it. Degrades honestly, since no live box was probed: an unreachable or unrecognized response can only ever produce "declared, NOT compared — verify in the Coolify UI", never invented drift and never a clean bill on an unread database. On the write side the same failure raises rather than guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
});
}
return schedules;
}
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
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`);
}
feat(service): set and diff per-container service hostnames via `urls` (#72) Services could not carry hostnames through cast: `desiredFromManifest` dropped a service's `domains` and warned they were a manual Coolify UI act, citing a re-checked "no flat `domains` on a 4.1.2 service, on any route." The audit (#72) disproved that — the same failure mode #51 corrected for backup schedules. The FLAT shape genuinely has no route; the per-container CAPABILITY was there at 4.1.2 all along. `POST /services` and `PATCH /services/{uuid}` both take a `urls` list ([{name, url}], url comma-joined) that `applyServiceUrls` matches to a `ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}` loads `applications` and returns each `fqdn` (verified against ServicesController v4.1.2). So services now speak the SAME per-container vocabulary a dockercompose app does: - **Manifest:** `service_domains: { <container>: [url] }` replaces the flat, unhonorable `domains` on a service (a flat list cannot name which container a hostname belongs to — exactly what `urls` requires). Canonicalized (keys and each URL array sorted) so container order never false-drifts. - **Write:** `serviceApiFields` builds `urls` on create and update. - **Read/diff:** a supplementary `GET /services/{uuid}` per service (`attachServiceDomains`, gated to `diff`/`apply` like backups) projects `applications[].fqdn` back into `service_domains`, so a declared hostname is compared every run — no perpetual drift, no manual UI step. - **Pre-flight:** a service create's `service_domains` joins `desiredDomainsOfCreate`, the more important because a service create whose domain conflicts is DELETED server-side before the 409 (rollback). Two limits stated out loud: the read is fail-closed (an unreachable/ unrecognized `GET /services/{uuid}` aborts rather than projecting empty and re-PATCHing forever), and `inventory --emit-draft` does not yet make the per-service GET, so a drafted service's hostnames are still declared by hand (same as backups) — draft/semantics say so. `npm run check` clean · 514 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
// A service's per-container hostnames live on `service.applications[].fqdn`,
// and only GET /services/{uuid} loads that relation ($service->load(['appli-
// cations','databases']), v4.1.2) — the environment-list GET that fetchLive
// reads does NOT. So reading a service's domains back to diff them (cast#72)
// costs this one extra GET per service. Returns the raw body; attachService-
// Domains projects `applications[].fqdn` into the manifest's service_domains
// shape and fails closed on any unrecognized answer.
async serviceByUuid(uuid: string): Promise<unknown> {
return this.get(`/services/${encodeURIComponent(uuid)}`);
}
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
// 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: diff and apply a database's backup schedule (#51) Backup schedules were write-only, filed under "known limitations" on the claim that "live Coolify state doesn't expose it back". The parenthesis was load-bearing and false: a schedule is not on the database's own GET, but it was never meant to be — it has its own route, GET /databases/{uuid}/backups, which cast had been POSTing to all along and had simply never read. The cost was exact. A database created before its `backup:` block was declared never got one (apply set the schedule only inside the create branch); a schedule deleted in the UI was invisible; and the `--full` diff that gates a production cutover passed with an unbacked-up production database. Shape settled from the source rather than the vendored spec, which documents the body as "Content is very complex. Will be implemented later.": DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns per $fillable (uuid, enabled, frequency, database_backup_retention_amount_locally). `frequency` round-trips verbatim: the controller validates it and stores $request->only(...) unchanged, with no mutator on the model. The "diffing it would flag spurious drift" fear was a guess about a read nobody had performed. - `backup` becomes a diffed field like any other (resolve.ts), replacing the side channel that carried it around the diff. - The live side reads the route (coolify.ts, fetchLive), and apply sets the schedule on UPDATE as well as create — POST or PATCH, decided by a read. - A disabled schedule is a row that backs nothing up: neither clean nor absent. cast diffs it and re-enables it. Degrades honestly, since no live box was probed: an unreachable or unrecognized response can only ever produce "declared, NOT compared — verify in the Coolify UI", never invented drift and never a clean bill on an unread database. On the write side the same failure raises rather than guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
// The SAME route as databaseBackups above, PARSED for the diff/apply half of
// the story (#51). destroy reads the raw body because it decides shape-meaning
// itself (readBackupState); diff/apply need a settled `frequency`/`retention`
// to compare and write, so this parses on top of the one HTTP call rather than
// duplicating it — one place fetches, two callers read it their own way.
//
// `undefined` means "could not read", which is a DIFFERENT fact from "has
// none" (see BackupRead). Every failure lands on `undefined`, INCLUDING a 404:
// it is tempting to read 404 as "no backups" (fetchLive does exactly that for a
// missing environment), but here a 404 is Coolify saying *the database* was not
// found, never "the database has no schedules" — the handler returns a plain
// `[]` for that, with a 200. Reading 404 as "none" would let a mistyped uuid
// report an unbacked-up database as clean, and let apply POST a second schedule
// onto a database that already had one.
async databaseBackupSchedules(uuid: string): Promise<BackupRead> {
try {
return parseBackupSchedules(await this.databaseBackups(uuid));
} catch {
return undefined;
}
}
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`);
}
}