cast/docs/semantics.md
Daniel Marin ab0eb27449
Merge pull request #53 from claude-hdb/fix/create-order
fix(apply): create databases and services before the applications that need them (#45)
2026-07-14 23:42:46 +01:00

700 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Behavior
The guarantees `cast apply` makes, the shapes it accepts, and the places
Coolify 4.1.2 does not cooperate. Extracted from the substrate repo this tool
was born in; the Coolify-source citations were verified against
`coollabsio/coolify` v4.1.2 and the vendored OpenAPI in `reference/`.
The command surface itself is in the README; this file is the behavior behind it.
## Team scoping
**A Coolify API token is scoped to exactly one team, and nothing below the team
scopes it.** `User::createToken` overrides Sanctum's and stamps the session's
team onto the token (`'team_id' => session('currentTeam')->id`); the API then
resolves every request through it — `getResourceByUuid($uuid,
getTeamIdFromToken())`, which walks `resource → environment → project →
team_id`.
The consequence that matters: **a wrong-team token does not error.**
`getResourceByUuid` returns `null` on a team mismatch, and `null` is
indistinguishable from *"this resource does not exist yet"* — which, to `apply`,
is an invitation to **create** it. An apply run with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns. That is
why the team assert is a correctness guarantee and not a hardening nicety, and
why it is **fail-closed**:
- Every environment in `environments.yaml` **must** declare `team:` (`id`,
`name`, or both). A missing team is a schema error — an environment whose
token cannot be verified is exactly the failure the binding exists to prevent.
- Every command that reaches a live Coolify (`apply`, `diff`, `server add`,
`smoke`) resolves `GET /teams/current` — the only endpoint that answers *"what
team does this token act as?"*, resolved from the token itself
(`TeamController@current_team` → `getTeamIdFromToken()`) — and compares it to
the binding **before its first read**, aborting on mismatch. Before the first
*read*, not merely the first write: a wrong-team `diff` reports "everything is
absent", which is the very lie an `apply` would then act on.
- An unreadable or unauthorized answer aborts too. It is not "no team"; it is an
unknown answer to the one question cast must not guess at.
**An Environment is not an auth boundary.** It has no `team_id` of its own (it
belongs to a project) and no API path scopes by it — Coolify environments are an
organizational construct. The team is the only boundary there is.
**A server belongs to exactly one team.** There is no pivot table and — unlike
`GithubApp` — no `is_system_wide` escape hatch; upstream confirms teams cannot
share a server and defers it to v5 (coollabsio/coolify#1820, #3235). Registering
a server under the wrong team is not fixable with a PATCH, which is why
`server add` takes `--env` and inherits the same assert.
**GitHub Apps, unlike servers, *can* be shared across teams**
`is_system_wide` is the supported mechanism, on both the read and write side:
```php
// GithubController@list_github_apps (backs GET /github-apps)
$githubApps = GithubApp::where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
// …
```
`POST /github-apps` validates and accepts `is_system_wide` (boolean), stamping
`team_id` from the token. So **one App flagged system-wide is visible and usable
from every team, and per-team App duplication is unnecessary.** Note the
corollary for cast: because `GET /github-apps` deliberately includes other
teams' system-wide Apps, **resolving a GitHub App by name is not a proxy for
being in the right team** — which is the second reason the team assert has to be
explicit.
**`dockercompose` build pack** (compose apps, box-B parity): a manifest
application whose `build.pack` is `dockercompose` declares
`build.compose_file` (path to the compose file in the checkout) and
`service_domains` (map of compose service name → `string[]` of URLs) instead
of the plain-app `port`/`healthcheck`/`domains` trio — those three live in the
compose file itself and are rejected by the manifest schema on a compose app;
conversely `service_domains`/`compose_file` are rejected on a non-compose app.
`domains` is schema-optional at the top level for this reason, but still
required (via a `superRefine`) for every non-compose app — no existing
manifest needs to change.
```yaml
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
service_domains:
api: ["https://api.widget.example.com"]
env_template: core.prod.env.template
```
**Checkout paths are absolute.** `build.compose_file`,
`build.base_directory` and `build.publish_directory` are all paths *inside the
repo checkout*, and all three must begin with `/` — the manifest schema refuses
them otherwise. This is Coolify's own rule, not cast's taste: on create it
validates `docker_compose_location` against
`ValidationPatterns::FILE_PATH_PATTERN` and `base_directory`/`publish_directory`
against `DIRECTORY_PATH_PATTERN` (both anchored on a leading slash; only the
directory pattern also admits the bare `/` root), and a `docker-compose.yaml`
with no slash comes back as a bare 422 — *after* `apply` has already created
the project and the environment. cast refuses at parse time instead, on every
verb, at zero API cost. It refuses rather than normalizes: the manifest is the
artifact under review, so the value is fixed in the file, once, not repaired in
memory on every run.
Internally cast keeps the map vocabulary (`docker_compose_domains:
{service: string[]}`) all the way through `resolve.ts`/`apply.ts`/diffing;
only `cli.ts`'s wire-translation layer (`applicationApiFields`) flattens it to
the Coolify request shape — an array of `{name, domain}` where `domain` is
that service's URLs comma-joined (verified against the
`/applications/private-github-app` and `PATCH /applications/{uuid}` request
schemas in `reference/coolify-openapi-4.1.2.json`). A compose app's create
payload also sets `connect_to_docker_network: true` — without it the stack
cannot reach the environment's managed Postgres/Redis resources at all, which
fails at runtime rather than at apply time.
Live-state projection (`projectLiveFields`) reads both fields back for a
compose app: `docker_compose_location` (a plain string on the GET model) and
`docker_compose_domains`, which the GET model documents as a nullable
**string**, not the structured array the write side accepts — parsed
defensively as JSON back into the internal map, degrading to "field omitted"
(not a crash) on anything that doesn't parse as a well-formed array of
`{name, domain}`. Projecting both is what keeps a matching re-apply a true
no-op instead of a spurious PATCH + stack redeploy every run. **This parsing
path is unverified against a real Coolify instance** — the read-back is only
confirmed by an overlay apply followed by an overlay-edit re-apply against a
live instance, which has not yet happened. If a live instance turns out not to expose
`docker_compose_domains` on read, apply's idempotency guarantee for the
domains half breaks and needs the same warn-and-drop treatment as service
domains below — that also removes the cutover mechanism (domains no longer
flip via re-apply), so it would need a runbook amendment, not a silent fix.
**Hostname overlay, compose apps:** `--hostname-overlay <file>` accepts a
per-service map value for a compose app's entry instead of the plain-app
`string[]`:
```yaml
core:
api: ["http://api.<PROD-IP>.sslip.io"]
landing: ["http://landing.<PROD-IP>.sslip.io"]
```
Only the named services' domain lists are replaced; other services in the
same app keep their manifest values. A map value naming an unknown service
errors, listing the app's known services; a map value against a non-compose
app errors (`hostname overlay gave a service map for non-compose app <name>`)
— the plain `string[]` shape keeps working unchanged for non-compose apps.
**Structural vs. full diff** are tied to what the configured token can do,
not a flag alone: **structural** diff needs only a standing read-only token
and never compares env values (output says so explicitly); **full** diff
(and `apply`, which always requires full) needs a session token with
`read:sensitive` and compares secret values too — but the output only ever
says `secret FOO differs`, never the value on either side.
**Apply semantics** (verbatim from the spec's Global Constraints — never
softened by an implementation detail):
- Apply never deletes. Resource removal or rename is a manual runbook act;
`diff` reports the orphan as such until that act happens.
- Apply never recreates a database resource under any circumstances.
- Apply creates the **project** and its **environment** when they are absent —
the two things a resource create has to name before it can name anything
else. Coolify hands a project it has just created its OWN default environment
(`production`), never ours, so without this the first apply against a
from-nothing project 404s on its first resource — *"Environment not found"*
and leaves the project behind, created and empty (#38). Read-before-write, so
an environment that already exists is never written to: adoption keeps working
exactly as it did, and this cannot regress an apply that works today.
- That default environment is then **removed** — the one delete cast performs,
and the only exception to *apply never deletes* (#40). What that rule protects
is things cast did not make; this is a byproduct of cast's own `POST /projects`
seconds earlier, holding nothing and having never held anything. Leaving it
meant every project cast created from nothing carried a permanently-empty
`production` beside the environment everything actually lives in — precisely
the shape that makes a box unreadable later (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 for ourselves). All three
conditions hold jointly or nothing is touched: **cast created the project in
this run** (never a project someone built by hand, whatever it carries), the
environment is **empty** (asked of Coolify — the details route is the only one
that eager-loads resources — not assumed from the first condition), and its
name is **not ours** (an `--environment production` keeps its `production`).
Best-effort: a delete that fails is reported and never fails the apply.
- On drift in a field the API cannot update in place (`build_pack`, a
database's `type`/`version`, a service's `type`), apply **fails loudly
naming the field** rather than recreating.
- Apply ends every mutated resource with a restart/redeploy
(`instant_deploy` on create, `/deploy` or `/restart` on update) — API
mutations land in Coolify's DB, not in running containers, so a create or
update that skipped this would silently not take effect.
- **Apply acts in dependency order, not manifest order** — `database`
`service``application` (#45), for creates *and* updates: a redeploy is a
redeploy, and an application restarted against a database whose own pending
change has not landed is the same failure one apply later. The order cannot be
computed — nothing in a manifest declares that `core` needs `postgres`, no
resource names another, so there is no graph to walk — so it is a fixed
kind-order (`KIND_ORDER` in `apply.ts`). The direction between the three kinds
is not in question, and three is few enough to legislate. `cast destroy` tears
down in its exact reverse: things come up in the order their dependencies
allow and go down in the reverse. Only the *acting* order changes; the diff
report still reads in manifest order (a resource is read where its author
wrote it), and nothing about clean/orphans/placement moves with it.
What that buys, and what it does not: an application is no longer created and
deployed against databases that **do not exist**, which made a first apply's
deploy fail by construction — a full build, a red deployment, and an operator
told to ignore it. It is **not** a readiness barrier.
`DeployController@deploy_resource` (v4.1.2) *queues*:
`queue_application_deployment(...)` for an application and
`StartDatabase::dispatch($resource)` for a database (only a service starts
synchronously, `StartService::run`). So cast orders its **requests**, and
Coolify runs them on its own queues. An app whose first boot must find a
*listening* database still races it; apply guarantees the database exists and
was asked to start first, not that it is up.
- Direction is one-way, manifest → Coolify, always.
- **Environment guards:** `apply` refuses if a var matching that environment's
`forbidden_var_patterns` is present in the resolved env **at all, regardless
of value** — "off" means absent, not `false` (see the README). `apply` also
refuses `--path` combined with `--env prod`: prod always reads the default
branch, so a feature-branch checkout can never reach it.
## The registry (`projects:`)
The top-level `projects:` block is the list of **which projects exist**, and in
which environments. It is the only place that says so: `environments:` says where
things deploy to, `environments.<env>.projects.<repo>` says how an already-named
project is placed, `github_apps` says how to clone one you have already named.
```yaml
projects:
heavy-duty/incubator:
environments: [prod, staging]
```
- **Keyed by the full `<org>/<repo>` slug, with no bare-`<repo>` fallback.** The
key is the repo; there is no `repo:` field. `github_apps` and
`environments.<env>.projects` accept a bare key because state files in the wild
are written that way; this block is new and has none, so it requires the slug —
a bare `<repo>` is unique only *within* an org.
- **`environments:` names OUR environments** — the keys of the `environments:`
block, the values `--env` takes — never Coolify's. Non-empty.
- **Optional.** A state file with no `projects:` block loads unchanged, and
`projectsIn` reports `[]` for every environment.
**Validated at parse time, so every verb refuses a registry that lies.** Two
refusals, both defending the same failure — *a silently skipped project reads
exactly like a clean one*, which makes silence, the most common report there is,
ambiguous:
1. **An environment that does not exist** (a typo in `projects.<slug>.environments`)
is an error naming the unknown environment and listing the known ones. Left
alone, the project would be registered into an environment no command can
visit: a fleet run skips it, reports nothing, exits clean.
2. **A binding the registry does not register.** Every
`environments.<env>.projects.<slug>` key must be a project the registry
registers *for that environment*. Otherwise the two blocks describe two
different fleets: a `destination_uuid` or `smoke_target` real enough for a
direct `cast apply <repo> --env <env>` to act on, and invisible to every
fleet run over that environment. Enforced **only when `projects:` is
present**, so pre-registry state files keep loading.
The registry is what makes two things possible, neither of which can be attempted
without a list to iterate: **fleet operations** (`--all`, below), and
**rebuild-from-state** — restoring a Coolify from the state repo, which is
otherwise an assumption, since you cannot restore what you cannot enumerate.
## Fleet runs (`--all`)
`cast diff --env <env> --all` and `cast apply --env <env> --all` act on **every
project the registry lists for that environment**, in place of the `<org>/<repo>`
positional. The projects are visited in the registry's sorted order
(`projectsIn`) — a fleet report a human reads top to bottom, and CI diffs, must
not reshuffle because someone appended a project.
**One implementation.** `--all` loops the *same* per-project path the single-repo
form runs (checkout → secrets → desired → bindings → team-asserted client → live
read → diff → optionally apply). There is deliberately no second, parallel fleet
code path: two implementations of "what a project run is" would drift, and drift
is the thing this tool exists to catch.
**The instance and the team are asserted once, before the first project's first
read.** One `--env` means one instance and one team for the whole run, so the
gate lands where it always did — strictly before the first live read, which is
already the lie a wrong-team token tells (see *Team scoping*).
### Fails closed on the aggregate
**A registered project cast cannot reach is an ERROR, never a skip.** "Cannot
reach" is every way a project can fail to answer: the clone failing, the manifest
carrying no block for this environment, the secret store being absent or
undecryptable, the Coolify project or environment being absent
(`LiveLookup.found === false`), and any HTTP error. They collapse into one
outcome because only one thing about them matters downstream — **this project was
not read** — and a silently skipped project reads exactly like a clean one. That
is the failure of #12/#18/#22 at fleet scale, and it would make *silence*, the
most common report there is, the least trustworthy one.
So the aggregate reports **coverage** (registered / read / clean / drifted /
unreachable), and the exit code ranks an unread project above a drifted one:
| verb | exit | meaning |
|---|---|---|
| `diff --all` | `0` | every registered project was **read**, and every one is clean |
| `diff --all` | `1` | every one was read, and at least one has drift |
| `diff --all` | `2` | a project could not be read. **Outranks drift**: an unreadable project is not a diff result, it is the absence of one |
| `apply --all` | `0` | every registered project applied |
| `apply --all` | `≠0` | anything else |
`fleetExitCode` defaults to `2` on any coverage shape it does not recognize — an
exit code is the only part of the report CI reads, so an unrecognized shape must
fail rather than pass.
### Opposite dispositions on failure, both deliberate
- **`diff --all` runs every project to completion.** A read that stops early hides
the drift in the projects it never reached; a read that continues costs nothing.
- **`apply --all` stops at the first failure**, and reports which projects were
applied and which were **not touched**. A write that continues costs everything:
the next project's apply would be a guess about whether the last one broke
something it depends on. `apply` is idempotent, so re-running after the fix is a
no-op over the projects that already applied.
`apply` keeps its usual position on an absent Coolify project — it *creates* it,
exactly as a single-project `apply` does (see *the read side*, and `LiveLookup`).
Only `diff` treats absence as unreachable, because `diff` may only ever describe a
target that already exists.
### Two refusals
- **An empty or absent registry refuses** (exit 2). `projectsIn` answers `[]` both
for a state file with no `projects:` block and for one whose registry names
nothing in this environment; to a fleet run they are the same thing — *nothing
to iterate* — and "0 projects, clean" is precisely the sentence this feature
exists to make impossible. The refusal names what was looked for, distinguishes
an unmigrated state file from a registry pointed elsewhere, and prints the YAML
to write.
- **`--all` is mutually exclusive** with the repo positional and with every
single-project coordinate: `--path`, `--project`, `--environment`, `--resource`,
`--hostname-overlay`. Each names ONE project's checkout, ONE project's Coolify
name, ONE box's resource names — none is true of the project beside it.
Fleet-wide they are meaningless at best and dangerous at worst: `--project X`
across a registry points every project at the same Coolify project, which on
`diff` is a false report and on `apply` is every manifest in the fleet written
into one project. The refusal names the offending flag and says what applying it
fleet-wide would have done.
## Placement (destinations)
A **destination** is the Docker network a resource is created on. It is declared
per project — `environments.<env>.projects.<repo>.destination_uuid` — because a
destination is scoped project × environment, and the environment block above it
says `server:`, which is exactly what two projects share.
**Enforced once, at create.** `apply` sends `destination_uuid` on every create
(applications, databases, services — Coolify runs identical destination logic in
all three controllers). It is never sent on update, and `apply` never moves a
live resource between networks.
**Not comparable, and therefore reported rather than compared.** Coolify 4.1.2
accepts a `destination_uuid` on write and returns a `destination_id` (an integer
primary key) on read, exposes no endpoint mapping one to the other, and in fact
has no destinations API at all (zero routes at `v4.1.2`). So the declared UUID
**cannot be verified against the live resource it was sent for** — by cast or by
anything else. Two consequences, both deliberate:
- `diff` never diffs the destination as a field. Doing so would compare a UUID
against an int and report drift that could never be resolved — a phantom
"update" on every run.
- `diff` instead groups live resources by the `destination_id` Coolify *does*
report. That int is opaque, but it is comparable **to itself**, which catches
the thing worth catching: a project whose resources do not all sit on one
network is a project whose isolation is broken. That is `split placement`, and
it is drift — non-clean, reported, and **not repaired** (same disposition as an
orphan).
- Whenever a destination is declared, `diff` says explicitly that it was *not*
compared. Silence would make an unverified setting read as a verified one —
the failure shape this document exists to avoid.
- Whenever one is **not** declared, `diff` says *that*, too:
`placement: server's default destination (none declared)`. Declaring nothing is
not the absence of a placement decision — it is one (cast sends no
`destination_uuid`, Coolify picks), and it was the only placement decision made
in silence until #41. It is reported even on a clean run that creates nothing,
because the trap is set precisely for projects that are already built: the day
their server gains a second destination, every project on it that declared no
destination stops being able to create at all.
**The multi-destination 400 is translated, not passed through** (#41). 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 the remedy goes in, and that
arrives at the **first create**, after `apply` has already made the project and
the environment. cast **cannot** pre-flight the condition: 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.
What cast can do is answer the question the 400 raises, and it does: the failing
resource, the server by the name the operator wrote (not its UUID), the exact
state-file path the UUID goes in
(`environments.<env>.projects.<org>/<repo>.destination_uuid`), the create-time
warning, and Coolify's own words kept verbatim. A run interrupted this way is
safe to re-run once the UUID is declared — the project and environment it already
made are adopted, not remade.
**Coolify's create-time behavior** (`ApplicationsController` ~L1003,
`DatabasesController` ~L1700, `ServicesController` ~L378 @ v4.1.2): a server with
**one** destination uses it and *ignores* any `destination_uuid` sent, never
validating it — so a typo is invisible there. A server with **more than one**
rejects a create that omits it (`400`), and rejects a UUID that belongs to
another server (`422`). The second case is why cast could not apply to a shared
box at all before this field existed. Citations: `reference/README.md`.
## Instance selection
**The Coolify a command talks to is an explicit, named value** — not a property
of whatever `<state>/.coolify.env` happens to contain at the moment. Resolution
order, highest first:
1. `--instance <name>``<state>/.coolify/<name>.env`
2. the environment's `instance:` binding in `environments.yaml`
3. `<state>/.coolify.env` (the default; unchanged when neither of the above is
used)
Two refusals, both fail-closed:
- **An unknown `--instance` aborts**, naming the instances that do exist. It
does *not* fall back to the default — that fallback is how a `--full` diff
meant for a legacy box gets run against production.
- **`COOLIFY_READ_ONLY=true` in an instance file makes it read-only**, and
`apply` / `smoke` / `server add` refuse it *before their first call*. The
guard is the **declaration**, not the token's scope: an instance configured
for inspection must not be writable even when the token it holds would permit
the writes. `diff`, `team` and `capture` still work against it — they read.
Every command that reaches a live Coolify prints which one, next to the team
assert.
## Adoption (`capture`)
`capture` is the only verb that writes *into* the state directory rather than
into Coolify, and the only one that reads a hand-built instance as a **source**
rather than as a target. It exists because cast is otherwise scoped to the
steady state and has no bootstrap path for a box that predates its manifest.
**The required set comes from the manifest, not from the box.** The names are
the `${…}` refs in that environment's env templates, read by the same parser
`apply` uses to demand them (`parseTemplate`, shared by `resolveTemplate` and
`templateRefs` — deliberately one grammar, because a drift between the two
would mean `capture` collects a different set than `apply` will later require,
which is the "a name silently missed" failure it exists to remove). So the store
it writes contains **exactly** the names the manifest requires: a live var
nobody asked for is not the store's business, and a template literal
(`NODE_ENV=production`) is not a secret.
**The mapping is not mechanical, and must not be.** Some entries encode
migration decisions rather than facts about the source box:
- A `DATABASE_URL` / `REDIS_URL` read off the source points at the **source
box's** Postgres/Redis. Copying it is confidently wrong in a way that looks
entirely plausible, and the target's real URL does not exist until Coolify
creates the resource. These are declared `generated_secrets:` in the manifest
environment and written as the literal `pending-coolify-generated`.
- staging's `ADMIN_EMAIL` must be the operator, not the source's value: staging
and prod share a Mailgun domain, so a staging box carrying the real address
can mail real users. That is `--override`.
A "capture everything" verb would therefore be silently wrong in a handful of
entries out of seventeen — worse than being wrong in all of them. So every
required name is **forced into a disposition**, and two of the four stop the
run:
| disposition | source | outcome |
| --- | --- | --- |
| captured | found live | value taken |
| generated | manifest `generated_secrets` (or `--generated`) | `pending-coolify-generated` |
| overridden | `$CAST_CAPTURE_<NAME>` | operator's value |
| **missing** | required by a template, absent live | **refuses** |
| **conflict** | one name, different live values on two resources | **refuses** |
`generated_secrets` is a **manifest** property, not a flag: the manifest is what
knows `DATABASE_URL` comes from a database it declares. An entry naming
something no template refs is a hard error — dead config here is not untidy but
dangerous, because it reads like a guard standing over a name while standing
over nothing, and the likeliest cause is a typo whose real name is then
*captured* from the source box instead of placeheld.
**Secret hygiene**, all enforced by tests against real values:
- The plan prints **names and provenance, never values**. (The one value-shaped
thing it prints is the `pending-coolify-generated` literal, which carries no
information about the source.)
- An `--override`'s value is read from `$CAST_CAPTURE_<NAME>`, **never from
argv** — a command-line value is visible in `ps` to every process on the box.
- Plaintext is piped to `age` on **stdin**: never a temp file, never stdout,
never shell history. The hand-run recipe this replaces wrote
`/dev/shm/prod.env` and relied on remembering to `shred -u` it.
- An existing store is **not overwritten** without `--force`: it may hold the
only copy of values the source box no longer has. Same disposition as apply's
never-delete.
**`capture` takes `diff`'s position on an absent target** (see `LiveLookup`),
and refuses one: against a project or environment that isn't there it would read
back zero live values and report every required secret as *missing* — an
alarming, meaningless report about the wrong box. It also inherits the team
assert (a wrong-team token reads back `null` for everything, producing the same
lie) and the `--path`-with-`--env prod` refusal (a feature-branch manifest must
not decide which names land in the prod store).
The final gate is a **typed confirmation** — the environment's own name, after
the plan. There is no `--yes`: a store written without someone reading the
provenance column is the outcome the verb exists to prevent. A closed stdin
aborts rather than hanging.
## Drafts (`inventory --emit-draft`)
`inventory` with no repo sweeps an instance. `--emit-draft <dir>` writes that
sweep down as a draft of cast's **own inputs** — a manifest per project, env
templates, an `environments.yaml` carrying the `projects:` registry, an age store
per project, and `UNCAPTURED.md`.
**A draft is a PROPOSAL. It is never desired state, and `apply` never reads it.**
sweep → emit draft → a human reads it → manifest PR → capture → apply
Same shape as `terraform import` → HCL. Every other rule in this section follows
from that one, and each is enforced rather than merely stated:
| refusal | why |
| --- | --- |
| a **non-empty** target directory | emitted over a repo that has a manifest, a draft would overwrite a reviewed spec with a live box's accumulated cruft — the one direction nobody reviews. **Adoption is one-way.** |
| an **existing manifest** at the path it would write | the same invariant, once more at the file (`assertNoExistingManifest`). For a declared project the manifest *is* the truth; `cast inventory <org>/<repo>` reconciles it instead. |
| `--emit-draft` with a **repo positional** | with a repo, inventory reconciles against a manifest that already exists — exactly the case where a draft must not be written. |
| **no age recipient** (and no `--no-secrets`) | a draft whose store was silently skipped *looks complete*: a manifest, templates full of `${REF}`s, and not one value anywhere. You would find out when `apply` refused, some time after the box those values were on stopped existing. |
| a project with **two populated environments** | a draft carries one environment per project. Picking would emit a blueprint of *half a box* that says nothing about the other half. `--environment` breaks the tie — as a **tiebreak, not a filter**: a project with one populated environment is drafted from it either way, or filtering by name would drop whole projects (each client site sits alone in Coolify's default `production`) out of a blueprint that claims to describe the box. |
**Provider-generated names are placeheld, never copied.** This is `capture`'s
discipline (see above), applied to a verb that has no manifest to tell it which
names are generated — so it decides **by name**, in two families:
1. Coolify's per-instance magic vars — `SERVICE_FQDN_*`, `SERVICE_URL_*`,
`SERVICE_PASSWORD_*`, `SERVICE_USER_*`, `SERVICE_BASE64_*`.
2. Any name carrying a **datastore** word (`DATABASE`, `DB`, `POSTGRES`, `PG`,
`REDIS`, `MONGO`, …) *and* a **connection** word (`URL`, `URI`, `DSN`, `HOST`,
`PORT`, `PASSWORD`, `USER`, …) as underscore-delimited segments —
`DATABASE_URL`, `UMAMI_DATABASE_URL`, `REDIS_URL_PROD`, `DB_HOST`.
Each such name is written as the literal `pending-coolify-generated`, listed in
the run's disposition table, and declared under the emitted manifest's
`generated_secrets:` — so a later `capture` placeholds it again with no flag to
remember. **Its live value is not written into any artifact.**
The rule errs **wide**, deliberately, because the two errors are not symmetric:
- over-match a real secret → it is placeheld, reported, and you put the value
back. Noisy, recoverable, **loud**.
- under-match a generated one → it is copied, and a box rebuilt from the draft
comes up **working**, reading and writing the *source box's* database, until
the day that box is deleted. Silent, unrecoverable, **quiet**.
It is a name-pattern rule, not a promise: a var that points at the source box
under a name cast does not recognize **will** be copied. The disposition table
(names and provenance, **never values** — same contract as the capture plan) is
what a reviewer reads to catch it.
**Every other live var becomes a `${REF}`**, with its value in the age store —
never a template literal. cast cannot know which of a box's vars are secret
(nobody wrote it down, which is why the verb exists), and the two mistakes are
again asymmetric: a non-secret in an encrypted store is untidy, a live API key
written as a literal into a manifest is a key in a git repo. One name carrying
**different values** on two resources is not a conflict cast resolves (one store
holds one value per name — see `capture`'s CONFLICT refusal): both are kept,
under `<RESOURCE>_<KEY>` refs, and the split is reported.
**`UNCAPTURED.md` is a first-class output, emitted on every run.** cast cannot
express everything a Coolify holds, and a blueprint that omits those things
without saying so is worse than no blueprint — in a disaster you would trust it
and rebuild a *different box*. Per resource, it names what was seen and could not
be written: `destination_id` (which Docker network — no destinations API in 4.1.2
to resolve it to the UUID `destination_uuid:` wants, #21), service hostnames (no
flat `domains` on a Coolify 4.1.2 service), Basic Auth / custom Traefik labels,
build and deploy command overrides, backup schedules (not exposed on a database's
GET — **a rebuild has no backups until you declare them**), database kinds cast
does not model (MySQL, MariaDB, MongoDB, KeyDB, Dragonfly, ClickHouse — named,
never silently dropped), env var names a cast template cannot express, and
applications whose build pack the manifest has no vocabulary for (left *out* of
the manifest rather than fabricated into the nearest pack).
It also carries the table below, because that is the file someone will be reading
at the worst possible moment.
### What a blueprint still cannot restore
| | |
| --- | --- |
| control plane | `rig coolify install` ✅ |
| structure | draft → manifest PR → `apply` ✅ |
| secret **values** | the age store + your key ✅ |
| **data** | Coolify's DB backups → S3 ✅ (a separate path) |
| **the GitHub App private key** | ❌ re-create by hand |
| **S3 access keys** | ❌ re-mint by hand |
The last two are not in the state repo — correctly, it holds no live credentials
— and cannot be regenerated from it. A DR runbook that does not say so is not a
runbook.
**What the box cannot tell you, and cast therefore does not invent:** the
`<org>/<repo>` slug comes from an application's git remote (the only place a live
box knows it), so a project with **no application** — a lone service — has no repo
on the box at all. cast writes the bare project name as the registry key, and the
registry's own parse-time refusal (*"a registry key has no meaning without its
org"*) then stops the file being used until a human supplies it. That refusal is
the design: the alternatives are inventing an org, or leaving the project out of
the registry — and a project missing from the registry is one every fleet run
skips **in silence**. Likewise `github_apps`: nothing Coolify returns about an
application says which App cloned it, so cast binds every repo to the instance's
only GitHub App when there is exactly one (there is no other it could be), and
writes a `REVIEW-…` marker when there is not.
## Cloning a private manifest
`resolveCheckout` resolves git credentials **inside cast**, in a fixed order —
`gh` borrowed as a per-invocation credential helper, then
`GITHUB_TOKEN`/`GH_TOKEN`, then the ambient helper — rather than leaving it to
whatever the workstation's git config happens to do.
It matters because `gh auth login` **does not** wire git's credential helper
(that is `gh auth setup-git`, a separate act most people never run), so a
perfectly logged-in operator still fell through to git's interactive
username/password prompt — which GitHub no longer accepts — and got an error
about *the repository* rather than about the missing credentials. There is no
routing around it for prod: `--path` is refused there, so the clone is the only
path and its auth is mandatory.
`GIT_TERMINAL_PROMPT=0` is set on every path, so cast can never hang on or fall
into that prompt. The token is never placed in the clone URL or in
`http.extraheader` — both leak it into `ps`, and the latter persists it into the
clone's `.git/config`; the helper reads it from the environment at run time, so
what lands in argv is the literal text `$CAST_GIT_TOKEN`. Note that the empty
`credential.helper=` reset clears **URL-scoped** helpers
(`credential.https://github.com.helper`, which is what `gh auth setup-git`
writes) as well as generic ones, so cast's chosen credential is genuinely the
one used — verified against a live private clone.
**Known limitations, not defects:**
- **Backup schedules are create-time only.** A manifest database's `backup`
block (`frequency`, `retention`) is applied only when the database is
first created; it is deliberately kept out of the diffed `fields` (live
Coolify state doesn't expose it back, so diffing it would flag spurious
drift every run — breaking idempotency). Changing a schedule on an
existing database is a runbook act, done by hand in the Coolify UI.
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2,
and is deliberately kept out of the diffed `fields` for the same
idempotency reason as backup schedules above.** The `/services`
create/update payload takes a structured per-container `urls` list, not
the manifest's flat `domains: string[]`, and the manifest has no
per-container name to build that list correctly from — so cast
drops it rather than send a malformed payload. Live Coolify service state
doesn't expose a flat `domains` back either, so if it stayed in `fields`
every domain-bearing service would diff as a perpetual update and every
`apply` would needlessly restart it — `desiredFromManifest` drops
`domains` from the service's `fields` and **warns**
(`service <name> declares domains (...), but apply cannot set them on
Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`)
once per run for every service that declared any. Set service hostnames
in the Coolify UI by hand.
- **"Include Source Commit in Build" cannot be enabled via the API in Coolify
4.1.2 — `apply` warns instead.** A dockercompose application whose build
consumes `SOURCE_COMMIT` as a **build arg** only receives it if the
per-application setting *Include Source Commit in Build* is on; Coolify
withholds it by default to preserve build cache. That setting
(`ApplicationSetting.include_source_commit_in_build`, default `false`) has no
API surface in 4.1.2: it appears in **zero** API controllers, and both the
create and PATCH allowlists in `ApplicationsController.php` (l.914, l.2368)
reject unrecognized keys outright (`"This field is not allowed."`), so cast
cannot smuggle it through — sending it would fail the whole request. Its only
writer is the Livewire *Advanced* tab
(`app/Livewire/Project/Application/Advanced.php:128`), i.e. a human in the UI.
Contrast `connect_to_docker_network`, which *is* in both allowlists and which
`apply` therefore does set on create. So `desiredFromManifest` **warns** once
per dockercompose application (`application <name> builds with dockercompose,
but apply cannot enable "Include Source Commit in Build" on Coolify 4.1.2 …`)
rather than pretending it is desired state. Enable it in the Coolify UI and
redeploy if your image bakes the SHA in at build time.
**This toggle gates the build-time arg only.** Coolify's **runtime** injection
of `SOURCE_COMMIT` is unconditional with respect to it
(`ApplicationDeploymentJob.php:2949` — `if (! $forBuildTime || …)`
short-circuits true at runtime), so a service that reads
`process.env.SOURCE_COMMIT` per request does **not** need the toggle at all.
What silently suppresses *that* value is an application-level env var of the
same name (`ApplicationDeploymentJob.php:2950`) — a distinct trap, and the
actual cause of a live box reporting `{"sha":"unknown"}`.
- **The redis default image is an unverified extrapolation.** Coolify's
"New Resource" wizard drives PostgreSQL version selection through a
verified `postgres:<version>-alpine` image string; Redis has no version
picker in that same wizard, so cast's `redis:<version>-alpine`
guess for a manifest-declared `version` is the same Docker Hub tag
convention applied by analogy, **not confirmed against a live Coolify
instance**. Recommendation: leave a manifest database's `version` unset
for `redis` until this has been verified once against bootstrap, letting
Coolify pick its own default image instead of risking a bad tag.