cast/README.md
claude-hdb 10a161ee07 feat: inventory sweeps the instance — a discovery verb that needed you to have discovered
`inventory` (#19/#20) reconciled a manifest against one project and one
environment THAT YOU NAME. But the premise of the verb is that you are looking
at a box you did not build — so you do not know those coordinates yet. It was
a discovery tool that required you to have already discovered, and the operator
went straight back to hand-curling /projects to find out where anything lived.

  cast inventory --env prod --instance box-b     # no repo → sweep

Every project, every environment, every resource the token can see. No manifest,
no store, no age key, no recipient. With a repo it reconciles exactly as before.

Worse than the missing sweep was how the targeted path FAILED. Pointed at a
project's `production` environment — auto-created by Coolify, and empty — it
reported:

    on the box, NOT in the manifest
        (none)
    5 difference(s) between the manifest and this box.

Every word true; the overall impression ("the box has nothing, the manifest has
five things") exactly the D-237 lie cast refuses everywhere else. The resources
were alive and serving production the whole time, in an environment named
`staging` that nobody had ever swapped. An environment with ZERO resources is
far more often the wrong coordinate than an empty one, so it now says so, and
names the sweep.

The sweep asserts the team first, and that matters more here than anywhere:
Coolify scopes what a token can see to its team, so a wrong-team token would
sweep an instance and truthfully report that it is empty.

Environment enumeration takes two roads — GET /projects/{uuid}/environments,
falling back to the relation on GET /projects/{uuid}. The vendored OpenAPI has
been wrong before, and this is the one path where failing to enumerate is worse
than being slow.

The stub in the new suite is shaped like the box this came from: three projects
(two of them unrelated third-party client sites nobody knew were there), an
empty auto-created `production`, and the real system in `staging`.

npm run check + build clean; 173 tests passing (was 169).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:07:17 +00:00

392 lines
18 KiB
Markdown

# cast
Point it at a repo and a state directory; it makes a **Coolify** instance match
what the repo declares. One-way, idempotent, never deletes.
Philosophy (shared with [rig](https://github.com/heavy-duty/rig) and
[claudebox](https://github.com/heavy-duty/claudebox)): **public tool, private
state.** cast holds no hostnames, no bindings, no secrets, nothing about *your*
infrastructure. It reads what you point it at and stores nothing, ever.
`rig` builds the boxes. `cast` fills them.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/cast/main/install.sh | bash
```
Needs `node` >= 22.12 and [`age`](https://github.com/FiloSottile/age) (secrets
are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
which is pure bash so it can run on a bare box — cast runs on **your** machine:
it is an API client, and a server should never install it.
The installer symlinks `cast` into `~/.local/bin` (or `/usr/local/bin` as root)
and, if that directory is not already on your `PATH`, appends it to your shell
profile — `.zshrc`, `.bashrc`/`.bash_profile`, or `config.fish`, whichever your
`$SHELL` reads — marked `# added by cast-install` and written only once. The
shell you ran the installer from does not inherit it (a `curl | bash` pipeline
is a subshell), so open a new shell or `source` the profile it names. Set
`CAST_NO_MODIFY_PATH=1` to be left alone and wire `PATH` yourself.
## The two inputs
cast joins a **manifest** (what to deploy) with **state** (where, and with what
values). Neither knows about the other, which is the whole point: a manifest can
live in a product repo without leaking your infrastructure, and your
infrastructure can be re-pointed at a new Coolify without touching a product.
**1. The product repo's `.infra/`** — committed, instance-blind:
```
.infra/
manifest.yaml # applications, databases, services, per environment
env/<app>.<env>.env.template # var NAMES + non-secret values; ${SECRET} placeholders
```
**2. A state directory** — private, yours:
```
environments.yaml # bindings: the team each env's token must belong to,
# which server it deploys onto, the S3 destination,
# GitHub App name, smoke target, guards
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
.coolify/<name>.env # …the same, for a NAMED instance (see below)
```
Pass it with `--state <dir>`, or set `CAST_STATE`. Defaults to the cwd.
## Commands
```sh
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast capture <org>/<repo> --env <env> [--generated <NAME>] [--override <NAME>]
cast inventory <org>/<repo> --env <env>
cast server add <name> --ip <ip> --key <file> --env <env> [--user root] [--port 22]
cast smoke --env <env>
cast team [--env <env>]
```
- **`apply`** — idempotent create-or-update of every manifest resource, then
redeploy what changed. One-way: it never deletes a resource that Coolify has
and the manifest doesn't. Clones the repo's default branch unless `--path`
points at a local checkout (refused with `--env prod` — prod always reads the
default branch).
- **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full`
also compares env vars. Exits non-zero when dirty, so CI can gate on it.
- **`inventory`** — what is actually *on* a box. **With no repo it sweeps the
instance** (every project, every environment, every resource — no manifest
involved); with a repo it reconciles, showing resources and env var **keys**
(never values) sorted into on-both / manifest-only / box-only. Needs no store,
no age key, and no recipient — it runs *before* adoption, which is the point of
it. A document, read by a person; nothing here is consumed by `apply`. See
*Adopting a hand-built instance*.
- **`capture`** — the adoption path: reads a hand-built instance's live env and
writes the environment's age store from it. See *Adopting a hand-built
instance* below.
- **`server add`** — uploads a server's private key and registers it with Coolify.
- **`smoke`** — contract test against `smoke_target`: proves Coolify's bulk env
endpoint still *upserts* rather than replacing. Run it after every Coolify
upgrade — `apply`'s never-delete guarantee rests on that behavior, and the
published OpenAPI does not describe it accurately.
- **`team`** — prints the team the configured token acts as. With `--env`, also
checks it against that environment's `team:` binding and exits non-zero on a
mismatch — the dry run for "would `apply` refuse?", answered without touching
anything.
Every command that reaches a live Coolify takes an `--env`, because every one of
them first asserts the token's team (below).
`--hostname-overlay` swaps domains for a pre-flight run against temporary
hostnames; re-applying **without** it is the cutover.
## Cloning: cast authenticates, and never prompts
`apply`, `diff` and `capture` clone the product repo (unless `--path` points at a
local checkout — refused for prod, which always reads the default branch). For a
private repo that needs credentials, and cast resolves them itself:
1. **`gh`**, borrowed as a credential helper for that one invocation — it does
not touch your global git config.
2. **`GITHUB_TOKEN` / `GH_TOKEN`** from the environment (the CI path).
3. Whatever git's own credential helper does, if you have one.
Being logged into `gh` is enough. You do **not** need `gh auth setup-git`
that separate act is what wires git's helper, and not running it is exactly how
you end up at git's interactive username/password prompt, which GitHub no longer
accepts. cast sets `GIT_TERMINAL_PROMPT=0` on every path, so it can never hang
there or hide a credentials failure behind an error about *the repository*. With
no credentials at all it says so, and names the fix.
The token is never put in the clone URL or in `http.extraheader` — both leak it
into `ps`, and the latter persists it into the clone's git config.
## Many Coolifys
`--instance <name>` reads `<state>/.coolify/<name>.env` instead of
`<state>/.coolify.env`. Every verb that reaches Coolify takes it.
```sh
cast diff heavy-duty/incubator --env prod --full --instance legacy
```
An environment can bind one, so `--env` selects the right control plane with no
flag at all:
```yaml
environments:
prod:
server: prod-box
team: { id: 1, name: heavy-duty }
instance: prod-cp # → <state>/.coolify/prod-cp.env
```
An explicit `--instance` still wins, so a one-off read against a legacy box needs
no edit to that file either. **With no flag and no binding, nothing changes**
`.coolify.env` is read exactly as before.
Two properties, both deliberate:
- **An unknown `--instance` refuses**, and names the instances that do exist.
Falling back to the default is how a diff meant for a legacy box gets run
against production.
- **An instance may declare `COOLIFY_READ_ONLY=true`**, and then `apply`,
`smoke` and `server add` refuse it — *before their first call*, and even
though the token itself would permit the writes. That turns "I pointed the
wrong token at the wrong box" from a live incident into an exit code.
Every command that reaches a Coolify now says which one, next to the team
assert. It is the most consequential input to any run, and the least visible.
## Adopting a hand-built instance
cast is otherwise scoped to the steady state: manifest → Coolify, forever.
Adoption is the one way in, and it has two verbs and a fixed order:
**`inventory` → you read it → a manifest PR → `capture``apply`**
**Look before you adopt.** A box nobody declared does not use your vocabulary:
its project is called whatever someone typed, its environment is Coolify's
default (`production`, not `prod`), and its resources are named by whoever
clicked *New Resource* that afternoon. `inventory` shows you both sides at once,
so those differences arrive together, as a document — instead of one at a time,
as refusals from a verb that is already halfway through a migration.
**First, sweep it — you cannot aim at coordinates you do not have yet:**
```sh
cast inventory --env prod --instance legacy
```
```
sweep — instance legacy (https://coolify.example.com)
Incubator
production (empty)
staging 2 applications, 2 databases, 1 service
application Incubator Stack v2
application Incubator Landing
database Incubator Database v2
La Familia Site
production 1 application
application lafamilia-web
```
Note what that costs you to *not* have: Coolify auto-creates a `production`
environment in every project, so the obvious guess is empty and the live system
is somewhere else entirely — under a name someone typed, in a project you may
not have known was there. An environment with **zero** resources is far more
often the wrong coordinate than an empty one, and `inventory` says so rather than
quietly reporting that the manifest has five things the box lacks.
**Then reconcile**, against a target you now know exists:
```sh
cast inventory heavy-duty/incubator --env prod --instance legacy \
--project Incubator --environment staging \
--resource core="Incubator Stack v2"
```
It never reads a value, needs no store and no key, and its output is **not**
desired state. What you do with it is decide, resource by resource and key by
key, what the manifest should *gain* and what is cruft that must not travel —
and land that as a manifest PR. Only then:
```sh
CAST_CAPTURE_ADMIN_EMAIL=me@example.com \
cast capture heavy-duty/incubator --env prod --instance legacy \
--override ADMIN_EMAIL
```
It reads the required secret **names** from the manifest's own env templates (the
`${…}` refs — the manifest already declares exactly this set), reads the live
values off the instance, and classifies every name:
| | |
| --- | --- |
| **captured** | found live, value taken |
| **generated** | the manifest's `generated_secrets` declares it provider-made → written as the literal `pending-coolify-generated`, never the live value |
| **overridden** | supplied by you, for a value that must *not* be carried over |
| **missing** | required by a template, absent live → **refuses** |
Then it prints a plan of **names and provenance — never values** — and waits for
you to type the environment's name.
Before any of that, it checks that the resources the manifest names **exist**.
An absent resource reads back exactly like one with no env vars set: every name
it declares reports *missing*, and `--override` would then have you hand-carry
values that are sitting right there under a different name — writing a perfectly
valid store while the actual finding (the manifest and the box disagree about
what this thing is called) is never discovered. So a resource that isn't there
refuses, and names what is. `inventory` is how you reconcile it.
### Three names that are not yours
A hand-built box names things without asking you, at three levels, and cast takes
each as a coordinate to *read* with — never as a reason to rename anything of
yours:
| flag | when |
| --- | --- |
| `--project <name>` | the project isn't named after the repo (`Incubator`, not `incubator`) |
| `--environment <name>` | the environment isn't named after `--env` (Coolify's default is `production`, not `prod`) |
| `--resource <manifest>=<live>` | a resource isn't named after the manifest's (`core` is `Incubator Stack v2` over there). Repeatable |
All three are **read-side only**`diff`, `capture`, `inventory`. They are
arguments to a one-off read, never manifest fields: a manifest that recorded a
legacy box's names would carry a dead machine's vocabulary forever. And `apply`
refuses `--resource` outright, because it creates resources under the manifest's
own names — an alias there could only mean *adopt the existing one instead*,
which is a different operation and would otherwise silently create a duplicate
beside the resource you were pointing at.
`--env` stays **ours**: it selects the manifest block, the `environments.yaml`
binding, the age key, the store path. `--environment` is *theirs*, on the wire,
and nothing else. Collapsing the two lets a box that is being deleted next week
name the environment of the box that replaces it — `apply` creates the
environment from that value, so it would be inherited permanently.
The mapping is not mechanical, and that is the whole design. A `DATABASE_URL`
copied off the source box points at the *source box's* Postgres: confidently
wrong, entirely plausible, and the target's real URL does not exist until Coolify
creates the resource. So the manifest declares those names, and cast placeholds
them:
```yaml
environments:
prod:
generated_secrets: [DATABASE_URL_PROD, REDIS_URL_PROD, UMAMI_DATABASE_URL]
```
It is a manifest property rather than a flag you have to remember, because the
manifest is what knows `DATABASE_URL` comes from a database it declares. (A
`generated_secrets` entry no template refers to is a schema error — a guard
standing over nothing is worse than no guard, because it reads like one.
`--generated <NAME>` covers a manifest that hasn't declared them yet.)
An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the
command line: argv is visible in `ps` to every process on the box. It exists for
values that must not survive the copy — staging and prod sharing a Mailgun
domain means a staging box carrying the real `ADMIN_EMAIL` can mail real users.
The store is encrypted to the environment's `age_recipient` (add it to
`environments.yaml` — it's the public half, safe to commit). Plaintext goes to
`age` on stdin: it is never a temp file, never on stdout, never in your shell
history. An existing store is not overwritten without `--force`.
**[docs/semantics.md](docs/semantics.md)** is the contract behind those
commands: what `apply` guarantees (never deletes, never recreates a database,
fails loudly rather than recreating on un-updatable drift), the `dockercompose`
build pack, the hostname-overlay shapes, and the places Coolify 4.1.2 does not
cooperate — each citation verified against `coollabsio/coolify` v4.1.2 and the
vendored OpenAPI in `reference/`. Read it before changing `apply`.
## Secrets, and attended applies
An environment's age identity is resolved in exactly two ways:
1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation
2. `~/.config/cast/age-<env>.key` — a standing key on this machine
That is the whole mechanism behind attended vs unattended applies: **an
environment whose key you never leave on disk can only be applied by someone who
injects it.** Keep a standing key for staging if you like; keep prod's in a
password manager and pass it per apply.
The state directory holds ciphertext. It must never hold the identity that opens
it.
## Teams: the one assert cast makes before it touches anything
Coolify API tokens are **team-scoped**, and a token pointed at another team's
resources **does not error**. The API resolves what the token cannot see to
`null` — and to a tool like cast, `null` is indistinguishable from *"this
resource does not exist yet"*, which is an invitation to create it. An `apply`
run with a wrong-team token would not fail; it would silently provision a
**duplicate set of resources into the wrong team**, on whatever server that team
owns. Silent, mutating, discovered late.
So every environment declares the team its token must belong to, and cast
refuses to do anything at all until it has checked:
```yaml
environments:
prod:
server: prod-box
team: { id: 1, name: heavy-duty }
```
Give `id`, `name`, or both — both are compared when both are given. `id` is the
true identity (names can be renamed); `name` is what makes the file readable.
Run `cast team` to print the values for the token you currently have configured.
The check is **fail-closed**: an environment with no `team:` is one whose token
cannot be verified, so it is a schema error, not a warning. It runs before the
first *read*, not merely before the first write — an unasserted `diff` against
the wrong team would report "everything is absent", which is precisely the lie
that an `apply` would then act on.
Nothing below the team scopes a token. A Coolify environment has no team of its
own (it hangs off a project) and no API path scopes by one: **Coolify
environments are an organizational construct, not an auth boundary.** The team
is the only boundary there is, so it is the one cast asserts.
## Guarding an environment
An environment may refuse variables by name pattern:
```yaml
environments:
prod:
server: prod-box
team: { id: 1, name: heavy-duty }
forbidden_var_patterns: ["^ALLOW_"]
```
`apply` then refuses if any such var is **present** on any resource, regardless
of value. `ALLOW_SEED=false` still fails: a var that exists can be flipped on
later in the Coolify UI without touching a manifest, so "off" has to mean absent.
This guard lives in your private state deliberately — not in the product's
manifest. A product-side change must not be able to lower its own guard.
## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
Coolify, restore a database backup into a target container.
**They run where cast runs — off the box.** They drive the Coolify API, or reach a
box over SSH; none of them expects to be executing *on* a server. Anything that
belongs on a box, as root, under a scheduler is [rig](https://github.com/heavy-duty/rig)'s
job, not cast's — including the nightly age-encrypted dump of the control-plane
database, which is now `rig coolify backup install`.
## Development
```sh
npm ci && npm run build && npm test
npm run check # biome
```