cast/src/diff.ts

499 lines
22 KiB
TypeScript
Raw Normal View History

fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
import { GENERATED_PLACEHOLDER } from "./capture.js";
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
import type { ResolvedEnv } from "./envtemplate.js";
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
import { isReservedEnvName, reservedConsequence } from "./reserved.js";
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 type ResourceKind = "application" | "database" | "service";
export type Desired = {
kind: ResourceKind;
name: string;
fields: Record<string, unknown>;
env?: ResolvedEnv;
};
export type Live = {
kind: ResourceKind;
name: string;
uuid: string;
fields: Record<string, unknown>;
env?: Record<string, string>;
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// The destination (Docker network) Coolify reports this resource on.
//
// NOT in `fields`, because `fields` is the desired-vs-live comparison
// vocabulary and this can never take part in it: Coolify 4.1.2 accepts
// `destination_uuid` on write and returns `destination_id` (an integer
// primary key) on read, and exposes no endpoint that maps one to the other.
// Putting it in `fields` would diff a UUID against an int and report drift
// that can never be resolved. See Placement.
destinationId?: number;
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
// Set ONLY when this database declares a `backup` block that cast could not
// read back (GET /databases/{uuid}/backups was unreachable, or answered a
// shape cast does not recognize — see BackupRead). The string is the reason,
// printed verbatim.
//
// Presence of this means: DO NOT COMPARE `backup` for this resource. Leaving
// `backup` merely absent from `fields` would NOT be equivalent — it would
// diff desired-against-nothing and report confident drift on a database that
// may well be perfectly backed up. An unreadable answer must produce neither
// drift nor a clean bill; it produces a line on the report. computeDiff is
// where that is enforced.
backupNotCompared?: string;
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
// The internal URL Coolify minted for this database (`internal_db_url`), when
// it is a database and the read carried one. NOT in `fields`: it is never
// written or compared as a database field — it is what an APPLICATION's
// ${resource:<this>.url} derives from (#60). Absent on applications/services,
// and on a database whose URL the read could not see.
internalDbUrl?: 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
};
export type FieldDiff = {
field: string;
desired: unknown;
live?: unknown;
updatable: boolean;
};
export type EnvDiff = {
key: string;
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
// `placeholder-conflict` is NOT a kind of `change`, and the distinction is
// the whole point: a `change` is a value cast is entitled to write, and this
// is one it must never write. The store holds GENERATED_PLACEHOLDER — "no
// real value exists yet, Coolify will make one" — and the live resource says
// Coolify already did. See diffEnv, renderDiff and applyPlan's refusal.
state: "add" | "change" | "remove-candidate" | "placeholder-conflict";
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
secret: boolean;
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
// Set to the RESOURCE NAME this var's value is derived from — its
// ${resource:<name>.url} — when it is a derived value rather than an authored
// one. Rendered as "derived from database <name>" rather than "secret differs",
// so a routine URL change (a rotation the derivation is meant to follow) never
// reads as an unexplained secret drift. Still `secret`, so the value itself is
// never printed either way (#60).
derived?: 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
};
export type Change = {
kind: ResourceKind;
name: string;
uuid?: string;
op: "create" | "update";
fieldDiffs: FieldDiff[];
envDiffs: EnvDiff[];
};
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// Where this project's resources actually sit, as far as Coolify will say.
//
// The destination cannot be diffed the way every other field is (see Live), so
// the alternative was to leave it out of the report entirely — and a setting
// that reads back as ABSENT rather than WRONG is the exact failure shape cast
// keeps legislating against (#12, #14, #17, #18). So it is reported instead of
// compared, and reported with the limit stated:
//
// - `declared` is what the state file asks for. cast sends it on create and
// CANNOT check it afterwards. Never silently — renderDiff says so.
// - `groups` is what Coolify answers, by `destination_id`. It is an opaque
// int, but it is comparable to ITSELF, and that is enough to catch the
// thing actually worth catching: a project whose resources do not all share
// one network is a project whose isolation is broken, whatever the numbers
// happen to be.
export type Placement = {
declared?: string;
groups: { destinationId: number; resources: string[] }[];
split: boolean;
};
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
// A reserved name (SOURCE_COMMIT, COOLIFY_*) found on a LIVE resource.
//
// NOT an orphan var, and the distinction is the whole point of this type. An
// orphan var is a live-only var the manifest does not declare, and its
// documented disposition is "apply never removes these; read them by eye" —
// cosmetic residue, filed under a heading that invites being read past. A
// reserved name is not residue: it is an ACTIVE SUPPRESSION of a value Coolify
// would otherwise inject (see reserved.ts), it is the difference between
// /version reporting a commit and reporting "unknown", and it is never
// cosmetic. So it comes out of that list and is reported as a finding, with the
// consequence attached.
//
// `apply never deletes` still holds, unchanged: cast reports it, a human deletes
// it in the Coolify UI.
export type ReservedVar = { kind: ResourceKind; name: string; key: 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
export type DiffReport = {
mode: "structural" | "full";
changes: Change[];
orphans: { kind: ResourceKind; name: string; uuid: string }[];
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
// Findings, not drift-to-repair. Never empty in structural mode by accident:
// structural mode reads no env vars at all, so it can find none — and says so.
reserved: ReservedVar[];
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
placement: Placement;
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
// Databases whose declared `backup` block cast could not verify this run.
// NOT drift (nothing was read, so nothing can be claimed) and so NOT counted
// against `clean` — but printed on every run that has any, because the whole
// point is that the assumption goes on screen at the moment it is made.
backupsNotCompared: { name: string; reason: 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
clean: boolean;
};
export const NON_UPDATABLE: Record<ResourceKind, string[]> = {
application: ["build_pack"],
database: ["type", "version"],
service: ["type"],
};
function eq(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function diffEnv(
desired: ResolvedEnv,
live: Record<string, string>,
): EnvDiff[] {
const diffs: EnvDiff[] = [];
for (const [key, v] of Object.entries(desired.vars)) {
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
const derived =
v.derived !== undefined ? { derived: v.derived.resource } : {};
if (!(key in live))
diffs.push({ key, state: "add", secret: v.secret, ...derived });
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
else if (live[key] !== v.value) {
// The second pass of the two-pass bootstrap, which for years only ever
// ran once. The store's value for a provider-generated secret is the
// literal `pending-coolify-generated` (capture.ts); the first apply sends
// it, Coolify creates the resource and replaces it with the real URL. From
// that moment the store is KNOWN-WRONG, and every diff since has printed
// `secret DATABASE_URL differs` — word for word what a legitimate rotation
// prints — while apply stood ready to PATCH the placeholder back over the
// live, working value and redeploy. So the placeholder gets its own state,
// and apply refuses on it (#47).
//
// Keyed on the STORE VALUE, not on the manifest's `generated_secrets:`
// list. Two reasons, and the first is fatal to the alternative:
//
// - `generated_secrets:` names store REFS (`DATABASE_URL_PROD`) while an
// env diff is keyed by env var KEY (`DATABASE_URL`) — the template maps
// one to the other and they routinely differ, so matching the list
// against these keys would sail straight past the real case. The value
// carries the same fact to where it is needed: resolveTemplate copies
// the store's value in verbatim, and `secret` is true exactly when the
// RHS was a single `${REF}`.
// - It is the stricter rule. A name dropped from `generated_secrets:`
// while the store still holds the placeholder is still a data-loss
// write; the placeholder is never a value anyone meant to ship.
//
// Only the UPDATE path can reach this: diffEnv runs solely against a live
// resource. On a create the placeholder is correct — Coolify replaces it —
// and that path emits `add`, untouched. A var absent live is likewise an
// `add`, and a live value that is ALSO the placeholder never gets here at
// all (the values are equal, so there is no diff to state).
const placeheld = v.secret && v.value === GENERATED_PLACEHOLDER;
diffs.push({
key,
state: placeheld ? "placeholder-conflict" : "change",
secret: v.secret,
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
...derived,
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
});
}
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
}
for (const key of Object.keys(live)) {
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
// A reserved name is deliberately NOT a remove-candidate: it is collected
// separately, as a finding (see ReservedVar). Leaving it here as well would
// report the same var twice under two headings, one of which says it is
// harmless. It also cannot be an `add`/`change`: the manifest side can never
// declare one — resolve.ts refuses the run first.
if (!(key in desired.vars) && !isReservedEnvName(key))
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
diffs.push({ key, state: "remove-candidate", secret: false });
}
return diffs;
}
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
// Read off the LIVE side, and off every live resource — not only the ones the
// manifest declares. A reserved name suppresses Coolify's injection on the box
// whether or not cast has ever heard of the resource carrying it, so scanning
// `changes` (which exists only for declared resources) would miss it on exactly
// the resource nobody is watching. Empty in structural mode, where no env var
// was read at all.
function reservedVars(live: Live[]): ReservedVar[] {
const found: ReservedVar[] = [];
for (const l of live) {
for (const key of Object.keys(l.env ?? {})) {
if (isReservedEnvName(key))
found.push({ kind: l.kind, name: l.name, key });
}
}
return found;
}
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
function computePlacement(live: Live[], declared?: string): Placement {
const byDestination = new Map<number, string[]>();
for (const l of live) {
// Coolify returns destination_id on applications, databases and services
// alike (none of the three controllers' removeSensitiveData hides it,
// v4.1.2). A resource that reports none is not evidence of a split — it is
// no evidence at all, so it is left out rather than grouped under a
// fabricated id.
if (typeof l.destinationId !== "number") continue;
const at = byDestination.get(l.destinationId) ?? [];
at.push(`${l.kind} ${l.name}`);
byDestination.set(l.destinationId, at);
}
const groups = [...byDestination.entries()]
.map(([destinationId, resources]) => ({
destinationId,
resources: resources.sort(),
}))
.sort((a, b) => a.destinationId - b.destinationId);
return { declared, groups, split: groups.length > 1 };
}
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 function computeDiff(
desired: Desired[],
live: Live[],
mode: "structural" | "full",
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
opts: { declaredDestination?: 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
): DiffReport {
const changes: Change[] = [];
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
const backupsNotCompared: { name: string; reason: 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
for (const d of desired) {
const l = live.find((x) => x.kind === d.kind && x.name === d.name);
if (!l) {
changes.push({
kind: d.kind,
name: d.name,
op: "create",
fieldDiffs: Object.entries(d.fields).map(([field, value]) => ({
field,
desired: value,
updatable: !NON_UPDATABLE[d.kind].includes(field),
})),
envDiffs:
mode === "full" && d.env
? Object.entries(d.env.vars).map(([key, v]) => ({
key,
state: "add" as const,
secret: v.secret,
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
...(v.derived !== undefined
? { derived: v.derived.resource }
: {}),
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
}))
: [],
});
continue;
}
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 unreadable-backup escape hatch. `backup` is dropped from the
// comparison entirely — not diffed against `undefined`, which is what
// "just leave it out of live.fields" would silently mean, and which would
// report drift cast has no evidence for and let apply write a schedule it
// never checked for. See Live.backupNotCompared.
if (l.backupNotCompared && "backup" in d.fields) {
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
}
const skipBackup = l.backupNotCompared !== 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
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
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
.filter(([field]) => !(skipBackup && field === "backup"))
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
.filter(([field, value]) => !eq(value, l.fields[field]))
.map(([field, value]) => ({
field,
desired: value,
live: l.fields[field],
updatable: !NON_UPDATABLE[d.kind].includes(field),
}));
const envDiffs =
mode === "full" && d.env ? diffEnv(d.env, l.env ?? {}) : [];
if (fieldDiffs.length > 0 || envDiffs.length > 0) {
changes.push({
kind: d.kind,
name: d.name,
uuid: l.uuid,
op: "update",
fieldDiffs,
envDiffs,
});
}
}
const orphans = live
.filter((l) => !desired.some((d) => d.kind === l.kind && d.name === l.name))
.map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid }));
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
const placement = computePlacement(live, opts.declaredDestination);
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
const reserved = reservedVars(live);
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
return {
mode,
changes,
orphans,
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
reserved,
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
placement,
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
backupsNotCompared,
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
// A split project is drift, and drift is not clean — the same disposition
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
// between networks; see renderDiff).
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
//
// A reserved name is not clean either, and for a stronger reason than drift:
// it is a live defect. The box it sits on is deploying green and reporting
// the wrong commit, and a `diff` that answered "clean" over it would be the
// last chance anyone had to notice.
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
//
// `backupsNotCompared` is deliberately NOT in this sum, and it is the one
// exception to the rule the line above states: it is an absence of evidence,
// not evidence of drift, and a run that failed because a read failed would be
// a run operators learn to force past. It gets a line instead — an
// unmissable one — rather than a non-zero exit.
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
clean:
changes.length === 0 &&
orphans.length === 0 &&
reserved.length === 0 &&
!placement.split,
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
};
}
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
// Every env var whose store value is still the generated-secret placeholder
// while the live resource holds a real one. The single reading of the report
// that both renderDiff and applyPlan use, so the warning and the refusal can
// never disagree about what counts as one.
//
// Names the key and the resource — NEVER the live value. Same rule as capture's
// disposition table: the point of the report is what to fix, not what the secret
// is, and a secret printed to a terminal is a secret in a scrollback buffer.
export function placeholderConflicts(
report: DiffReport,
): Array<{ kind: ResourceKind; name: string; key: string }> {
return report.changes.flatMap((c) =>
c.envDiffs
.filter((e) => e.state === "placeholder-conflict")
.map((e) => ({ kind: c.kind, name: c.name, key: e.key })),
);
}
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 function renderDiff(report: DiffReport): string {
const lines: string[] = [];
if (report.mode === "structural") {
lines.push(
"env vars not compared (structural mode — full diff needs a session token with read:sensitive)",
);
}
for (const c of report.changes) {
lines.push(`${c.op} ${c.kind} ${c.name}`);
for (const f of c.fieldDiffs) {
lines.push(
` ${f.field}: ${JSON.stringify(f.live)}${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
);
}
for (const e of c.envDiffs) {
if (e.state === "remove-candidate")
lines.push(
` env ${e.key}: live-only (orphan var — apply never removes)`,
);
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
// Said in words no rotation prints. `secret X differs` was the ONLY signal
// this ever had, and it is exactly what a legitimate rotation of the same
// secret looks like — an operator reading it had no way to tell the two
// apart, which is how a plan to destroy a live database reads as routine.
else if (e.state === "placeholder-conflict")
lines.push(
` secret ${e.key}: store holds the generated-secret PLACEHOLDER, live holds a real value — apply would OVERWRITE it`,
);
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60) Add a ${resource:<name>.url} env-template ref that resolves to the internal URL of a database the same manifest declares, read back from the live resource's internal_db_url — never stored in the age store, never decrypted, never printed. This deletes the two-pass generated-secret bootstrap for a database's own URL rather than automating it: no placeholder, no stored copy to drift or overwrite, and a rotated password is simply followed on the next apply. Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff time against databases already on the box (so a matching app shows no drift — killing the "secret DATABASE_URL differs" noise that ran on every plan), and in the executor at apply time against a database created earlier in the same run (the from-nothing case; apply acts databases-before-applications, #45). The unresolved sentinel is never written — the executor refuses, rather than write a blank that boots the app pointed at nothing, and re-running once the database is up resolves it as an ordinary update. A ${resource:X.url} naming a database the manifest does not declare, or an attribute other than .url, is a hard plan-time error refused by every verb that opens a template (apply, diff, capture). generated_secrets and the two-pass bootstrap remain for the residual class — a provider-generated value that genuinely is not derivable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
// A derived value, said in words that are not a rotation's: it is not a
// secret that "differs", it is a URL cast reads back from the database it
// created and keeps the app pointed at. `add` = the app does not carry it
// yet (a first apply, or a database made this run); `change` = the live
// value has drifted from the database's current URL and apply will follow
// it. Never the value — same rule as any secret.
else if (e.derived)
lines.push(
e.state === "add"
? ` ${e.key}: derived from database ${e.derived} — apply will set it`
: ` ${e.key}: derived from database ${e.derived} — live differs, apply will follow it`,
);
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
else if (e.secret) lines.push(` secret ${e.key} differs`);
else lines.push(` env ${e.key}: ${e.state}`);
}
}
for (const o of report.orphans) {
lines.push(
`orphan ${o.kind} ${o.name} (live, not in manifest — removal is a manual runbook act)`,
);
}
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
// Printed as a FINDING, in its own paragraph, with the consequence attached —
// not as a one-line entry in a list of things that are fine. The failure this
// catches is green: the deploy worked, the health check passed, and this line
// is the only place anything says otherwise. It has to be readable as an
// instruction to go and delete something, because that is what it is.
for (const r of report.reserved) {
lines.push(
`FINDING: ${r.kind} ${r.name} carries env var ${r.key} — DELETE IT (Coolify UI)`,
` ${reservedConsequence(r.key)}`,
" cast declares no such var and never will (it refuses a manifest that does),",
" and `apply` never deletes — so this one is yours to remove, by hand, in the UI.",
);
}
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 honest fallback, on the same principle as the placement line below: cast
// read for the schedule and did not understand the answer, so it says so here
// rather than dropping the field and letting a clean report imply a backed-up
// database. A `backup:` block that produced no line above and no line here IS
// compared, and IS clean.
for (const b of report.backupsNotCompared) {
lines.push(
`backup schedule for database ${b.name} declared, NOT compared — verify in the Coolify UI`,
` (${b.reason})`,
);
}
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
const { placement } = report;
if (placement.split) {
lines.push(
`split placement: these resources sit on ${placement.groups.length} different destinations`,
);
for (const g of placement.groups)
lines.push(` destination ${g.destinationId}: ${g.resources.join(", ")}`);
lines.push(
" a project's resources must share one destination — that is what the isolation IS.",
" apply never moves a live resource between networks: resolve manually (runbook act).",
);
} else if (placement.declared && placement.groups.length === 1) {
lines.push(
`placement: all resources on destination ${placement.groups[0].destinationId}`,
);
}
if (placement.declared) {
// Said out loud on every run that declares one, rather than left to be
// inferred from its absence. cast enforces this UUID exactly once — at
// create — and can never check it again; an operator who thinks `diff`
// covers it is an operator who thinks the isolation is verified.
lines.push(
`destination ${placement.declared} declared, NOT compared — Coolify 4.1.2 takes`,
" destination_uuid on write and returns destination_id on read, and has no endpoint",
" mapping one to the other. cast sends it on create; nothing can verify it after.",
);
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
} else {
// The other half of the same principle, and #41: declaring NOTHING is also a
// decision about placement — cast sends no destination_uuid and lets Coolify
// pick — and it was the one placement decision made in silence. The inference
// lived in a source comment ("the server's only destination, which is what
// Coolify picks anyway"), which is exactly where an assumption is invisible
// until it is wrong.
//
// This reverses a judgment cast used to hold explicitly ("a line on every diff
// that says nothing is how a report stops being read" — the test this replaces).
// The line does not say nothing: it says which network the next create lands on,
// which is a fact about this run and a wrong one to have to infer from a blank
// space. It stays on a run that creates nothing, too, because the trap is set
// precisely for projects that are already built and clean — the day their server
// gains a second destination, every one of them that declared no destination
// stops being able to create at all, and nothing will have warned them.
//
// Two lines, not three: the old judgment was not wrong about noise, only about
// which side of it silence was on.
lines.push(
"placement: server's default destination (none declared) — cast sends no destination_uuid,",
" so Coolify picks; a server with more than one destination refuses the create outright.",
);
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
}
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
// In the tail too, not only against the var: the per-var line sits inside a
// change block that can be dozens of lines up, and the summary is the line an
// operator actually reads before typing `apply`. It says what apply will do,
// which is nothing at all.
const conflicts = placeholderConflicts(report);
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
lines.push(
report.clean
? "clean"
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)${
fix: never write an env var whose name Coolify injects itself (#50) Coolify injects SOURCE_COMMIT and the COOLIFY_* family into an application's runtime environment itself, and SKIPS its own injection of a name the resource already carries a var of (ApplicationDeploymentJob.php v4.1.2, line 2994 — `->where('key', 'SOURCE_COMMIT')->isEmpty()`). A resource-level var of that name therefore SUPPRESSES the platform's value. An empty one suppresses it just as completely: presence, not value. And it fails green — the deploy succeeds, health checks pass, and the only symptom is /version reporting "unknown", the endpoint a production cutover is gated on (D-266). The rule is now a property of cast, not of one code path. A new src/reserved.ts owns it, and every place cast touches an env var honors it: - resolve — every manifest read (desiredFromManifest, requiredSecrets, manifestResources) refuses a template declaring a reserved name, before any write. So apply, diff, capture and inventory all refuse identically. - draft — a reserved name read off a live box gets its own provenance, `suppressed`: out of the template, out of the age store, its live value read into no artifact, and named in UNCAPTURED.md with the consequence. - diff — promoted out of the remove-candidate orphan list ("apply never removes these; read them by eye") and printed as a FINDING with its consequence. Not clean. apply still never deletes: cast reports, the human removes it. - capture (classify) and cli (syncEnv) carry the same assertion at the file and at the wire — unreachable through the CLI today, and kept because the invariant is "cast never writes one", not "the CLI happens to check first". - smoke writes an env var too; its probe names are asserted outside the space. The rule lives in cast's code, NOT beside forbidden_var_patterns in private state: that one is policy an environment may set for itself, this one is a fact about Coolify, true on every box — nothing a manifest change could lower. 19 tests in test/reserved.test.ts, one per path. Closes #50.
2026-07-14 22:29:23 +00:00
report.reserved.length > 0
? `, ${report.reserved.length} reserved-name FINDING(s)`
: ""
fix(apply): refuse to write the generated-secret placeholder over a live value The bootstrap is two-pass and only the first pass was ever safe to repeat. The store holds `pending-coolify-generated` for a provider-generated secret; the first apply sends it, Coolify creates the Postgres/Redis and replaces it with the real URL. From that moment the store is known-wrong — and `diff` and `apply` had never heard of the literal cast itself invented to say so. `diff` printed `secret DATABASE_URL differs`, which is word for word what a legitimate rotation prints, and `apply` stood ready to PATCH the placeholder back over the live URL and redeploy every consumer onto it. Coolify's bulk env endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found and its value overwritten), so nothing on the far side stopped it either. - diffEnv gives the placeholder its own state, `placeholder-conflict`, when the store holds it and the live resource holds anything else. Live-also- placeholder, absent live, and the create path are unchanged. - renderDiff says it in words no rotation prints, and counts it in the summary. - applyPlan REFUSES on it, before any resource is touched — same fail-closed shape as the not-updatable refusal. The message names the key and the resource, never the live value, and points at the remedy (#48). Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var key (DATABASE_URL). Matching the list against these keys would have sailed past the very case that motivated the issue. Closes #47. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
}${placement.split ? ", split placement" : ""}${
conflicts.length > 0
? `, ${conflicts.length} generated-secret PLACEHOLDER conflict(s) — apply will REFUSE`
: ""
}`,
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
);
return lines.join("\n");
}