cast inventory: see the box before you adopt it — the missing first step #19

Closed
opened 2026-07-13 18:06:41 +00:00 by dan-claude-bot · 0 comments
dan-claude-bot commented 2026-07-13 18:06:41 +00:00 (Migrated from github.com)

The missing verb

cast can describe a Coolify it built (diff), change one (apply), and
take secret values from one for names a manifest already declares
(capture). It cannot tell you what is actually on a box you did not build.

That is the first thing anyone needs when adopting an existing deployment, and
today it is done by hand:

curl -s -H "Authorization: Bearer $T" $B/api/v1/applications | jq -r '.[] | "\(.uuid)  \(.name)"'
curl -s -H "Authorization: Bearer $T" $B/api/v1/services     | jq -r '.[] | "\(.uuid)  \(.name)"'
curl -s -H "Authorization: Bearer $T" $B/api/v1/applications/<uuid>/envs | jq -r '.[].key' | sort
# …then eyeball it against the manifest

Every input to that pipeline is something cast already has — the client, the
token, the instance, the project/environment resolution. A human is shuffling
cast's own inputs through a terminal, which is the same complaint that produced
capture (#15).

What it cost us, concretely

During the prod migration (D-193) we went into the adoption blind, and paid for
it three times in one session:

  1. capture refused: no project named incubator — the box calls it
    Incubator. An inventory would have shown that instantly. (The refusal
    worked; the blindness is what made it a surprise.)
  2. capture refused again: no environment named prod — the box calls it
    production (Coolify's default). This one was "fixed" by renaming our
    environment to match the legacy box — letting a machine that is being deleted
    next week name the new box's environment forever. Reverted, and now #17.
  3. capture then reported 15 of 15 required names missing from a box that is
    serving production and sending mail right now — because the manifest's
    resources (core, umami) are named something else on the box. Now #18.

Every one of those is the same root cause: we could not see the box. The
refusals were correct and the tool behaved as designed — but each one arrived as
a surprise, one at a time, mid-migration, and the "obvious" fix to two of them
was to bend the manifest toward the legacy box. An inventory would have surfaced
all three in a single read, before anything was decided.

Proposal

cast inventory <org>/<repo> --env <env> [--instance <name>] [--project <name>] [--environment <name>]

Reads the live instance and writes a review artifact — cast's own format,
locally, for a human to read and reconcile. Explicitly not desired state and
not a store:

  • resources found (applications, services, databases), with their names, build
    packs, base directories, ports, domains
  • env var keys per resource (names only by default — never values)
  • a reconciliation section: what the manifest declares that the box lacks,
    what the box has that the manifest doesn't know about, and what matches — the
    same three buckets a human is otherwise assembling by eye

The output is the input to a manifest PR, not to apply. The lifecycle is:

inventory → human reads → manifest PR → captureapply

cast is missing the first step, which is why every mismatch currently surfaces as
a refusal from a verb running much later, when the operator is already committed
to a course of action.

Why this must NOT just become "capture everything"

Worth stating, because it is the natural next thought and it is a trap.

The store must contain exactly what the manifest declares — no more, no
fewer. If a dump's values became the store, the new prod app would boot with the
source box's DATABASE_URL, and it would work: migrations run, users
submit, data flows — into the old box's Postgres. You would find out the day the
old box is deleted. That is why capture places pending-coolify-generated and
refuses rather than copies.

So: inventory may read everything, because its output is read by a person.
capture may only ever write what the manifest declares, because its output is
read by apply. Same box, two different consumers, two different contracts.

Acceptance

  • cast inventory against a hand-built instance lists every resource and every
    env key, with no secret values in the default output.
  • It reports the three-way reconciliation (manifest-only / box-only / both)
    against the repo's manifest.
  • Its output is never consumed by apply — it is a document, not state.
  • Running it requires only a read token (no read:sensitive unless values are
    explicitly requested).
## The missing verb cast can **describe** a Coolify it built (`diff`), **change** one (`apply`), and **take secret values** from one for names a manifest already declares (`capture`). It cannot **tell you what is actually on a box you did not build**. That is the first thing anyone needs when adopting an existing deployment, and today it is done by hand: ```sh curl -s -H "Authorization: Bearer $T" $B/api/v1/applications | jq -r '.[] | "\(.uuid) \(.name)"' curl -s -H "Authorization: Bearer $T" $B/api/v1/services | jq -r '.[] | "\(.uuid) \(.name)"' curl -s -H "Authorization: Bearer $T" $B/api/v1/applications/<uuid>/envs | jq -r '.[].key' | sort # …then eyeball it against the manifest ``` **Every input to that pipeline is something cast already has** — the client, the token, the instance, the project/environment resolution. A human is shuffling cast's own inputs through a terminal, which is the same complaint that produced `capture` (#15). ## What it cost us, concretely During the prod migration (D-193) we went into the adoption blind, and paid for it three times in one session: 1. `capture` refused: **no project named `incubator`** — the box calls it `Incubator`. An inventory would have shown that instantly. (The refusal worked; the *blindness* is what made it a surprise.) 2. `capture` refused again: **no environment named `prod`** — the box calls it `production` (Coolify's default). This one was "fixed" by renaming **our** environment to match the legacy box — letting a machine that is being deleted next week name the new box's environment forever. Reverted, and now #17. 3. `capture` then reported **15 of 15 required names missing** from a box that is serving production and sending mail *right now* — because the manifest's resources (`core`, `umami`) are named something else on the box. Now #18. Every one of those is the same root cause: **we could not see the box.** The refusals were correct and the tool behaved as designed — but each one arrived as a surprise, one at a time, mid-migration, and the "obvious" fix to two of them was to bend the manifest toward the legacy box. An inventory would have surfaced all three in a single read, before anything was decided. ## Proposal ``` cast inventory <org>/<repo> --env <env> [--instance <name>] [--project <name>] [--environment <name>] ``` Reads the live instance and writes a **review artifact** — cast's own format, locally, for a human to read and reconcile. Explicitly **not** desired state and **not** a store: - resources found (applications, services, databases), with their names, build packs, base directories, ports, domains - env var **keys** per resource (**names only by default — never values**) - a **reconciliation section**: what the manifest declares that the box lacks, what the box has that the manifest doesn't know about, and what matches — the same three buckets a human is otherwise assembling by eye The output is the input to a *manifest PR*, not to `apply`. The lifecycle is: **inventory → human reads → manifest PR → `capture` → `apply`** cast is missing the first step, which is why every mismatch currently surfaces as a refusal from a verb running much later, when the operator is already committed to a course of action. ## Why this must NOT just become "capture everything" Worth stating, because it is the natural next thought and it is a trap. The **store** must contain exactly what the manifest declares — no more, no fewer. If a dump's values became the store, the new prod app would boot with the **source box's** `DATABASE_URL`, and it would *work*: migrations run, users submit, data flows — into the old box's Postgres. You would find out the day the old box is deleted. That is why `capture` places `pending-coolify-generated` and refuses rather than copies. So: `inventory` may read everything, because its output is **read by a person**. `capture` may only ever write what the manifest declares, because its output is **read by `apply`**. Same box, two different consumers, two different contracts. ## Acceptance - `cast inventory` against a hand-built instance lists every resource and every env key, with **no secret values** in the default output. - It reports the three-way reconciliation (manifest-only / box-only / both) against the repo's manifest. - Its output is never consumed by `apply` — it is a document, not state. - Running it requires only a read token (no `read:sensitive` unless values are explicitly requested).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#19
No description provided.