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;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
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(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`);
|
|
|
|
|
}
|
|
|
|
|
}
|