cast capture: adopt a hand-built Coolify instance into the age secret store #15

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

Problem

cast is scoped to the steady state: manifest → Coolify, forever. It has no
adoption path — no way to bootstrap the age secret store from a Coolify
instance that was built by hand, before any manifest existed.

That is a real, recurring-once-per-legacy-box operation, and it is on the
critical path of the prod migration (D-193, Task 7 "secrets capture"). Today the
operator does it by hand:

curl -s -H "Authorization: Bearer $T" $B/api/v1/applications/<uuid>/envs | jq …
# hand-assemble 17 name=value pairs into /dev/shm/prod.env
age -r <recipient> -o secrets/incubator.prod.env.age /dev/shm/prod.env
shred -u /dev/shm/*.env

Every input to that pipeline is something cast already has:

  • the required secret names — the ${...} refs in the manifest's env
    templates (.infra/env/*.env.template)
  • the store location — the --state dir
  • the recipients — (should be) environments.yaml
  • the live values — its own Coolify client already reads envs with
    read:sensitive

So a human is shuffling cast's own inputs through a terminal. The failure modes
are exactly the ones cast exists to remove: a name silently missed (the template
substitutes empty → the app boots misconfigured), or a plaintext secret leaking
through shell history, terminal scrollback, or a tmp file that never got
shredded.

Why this can't be a naive dump

The mapping is not purely mechanical. Some entries encode migration
decisions rather than facts about the source box. From the live case:

  • DATABASE_URL_PROD, REDIS_URL_PROD, UMAMI_DATABASE_URL must be written as
    the literal pending-coolify-generated — the source box's real URLs point at
    the source box's Postgres/Redis. Copying them would be confidently wrong in
    a way that looks entirely plausible, and the target's real URLs don't exist
    until Coolify creates the resources.
  • staging's ADMIN_EMAIL must be the operator, not the source value —
    staging and prod share a Mailgun domain, so a staging box carrying the real
    ADMIN_EMAIL can mail real users.

A "capture everything" verb would produce a store that is wrong in ~4 of 17
entries, silently. So the verb must force disposition rather than guess.

Proposal

cast capture <org>/<repo> --env <env> [--state <dir>] [--instance <name>]
  1. Read the required secret names from the manifest's env templates for <env>
    (the ${NAME} refs — the manifest already declares exactly this set).
  2. Read live env values from the target instance (read:sensitive).
  3. Classify every required name:
    • captured — found live, value taken
    • generated — the manifest declares it as provider-generated → write the
      literal pending-coolify-generated, never the live value
    • missing — required by the template, absent live → refuse, don't
      write an empty
    • overridden — value supplied by the operator on the command line, for
      the cases where the source value must not be carried over
  4. Print a plan of names and provenance — never values — and require
    confirmation.
  5. On confirm, encrypt to the env's recipient and write
    secrets/<repo>.<env>.env.age. Plaintext never touches disk outside a
    tmpfs, and never touches stdout.

Marking a name as generated should be a manifest property, not a flag the
operator has to remember — the manifest already knows DATABASE_URL comes from
a Coolify-created Postgres.

Acceptance

  • cast capture heavy-duty/incubator --env prod produces a store containing
    exactly the names the manifest requires — no more, no fewer.
  • Provider-generated names are placeholders, never carried over from the source
    box.
  • A name required by the template but absent from the source refuses the run.
  • No secret value is ever written to stdout, to a non-tmpfs path, or left behind
    on exit.

Note on urgency

Deliberately not urgent. This verb writes the prod secret store; new code
whose bugs land there, on a migration's critical path, is a worse trade than one
attended manual pass. File now, build when it isn't load-bearing.

## Problem cast is scoped to the steady state: manifest → Coolify, forever. It has no **adoption path** — no way to bootstrap the age secret store from a Coolify instance that was built by hand, before any manifest existed. That is a real, recurring-once-per-legacy-box operation, and it is on the critical path of the prod migration (D-193, Task 7 "secrets capture"). Today the operator does it by hand: ```sh curl -s -H "Authorization: Bearer $T" $B/api/v1/applications/<uuid>/envs | jq … # hand-assemble 17 name=value pairs into /dev/shm/prod.env age -r <recipient> -o secrets/incubator.prod.env.age /dev/shm/prod.env shred -u /dev/shm/*.env ``` Every input to that pipeline is something **cast already has**: - the required secret **names** — the `${...}` refs in the manifest's env templates (`.infra/env/*.env.template`) - the **store location** — the `--state` dir - the **recipients** — (should be) `environments.yaml` - the **live values** — its own Coolify client already reads envs with `read:sensitive` So a human is shuffling cast's own inputs through a terminal. The failure modes are exactly the ones cast exists to remove: a name silently missed (the template substitutes empty → the app boots misconfigured), or a plaintext secret leaking through shell history, terminal scrollback, or a tmp file that never got shredded. ## Why this can't be a naive dump The mapping is **not** purely mechanical. Some entries encode migration decisions rather than facts about the source box. From the live case: - `DATABASE_URL_PROD`, `REDIS_URL_PROD`, `UMAMI_DATABASE_URL` must be written as the literal `pending-coolify-generated` — the source box's real URLs point at the *source box's* Postgres/Redis. Copying them would be confidently wrong in a way that looks entirely plausible, and the target's real URLs don't exist until Coolify creates the resources. - staging's `ADMIN_EMAIL` must be the **operator**, not the source value — staging and prod share a Mailgun domain, so a staging box carrying the real `ADMIN_EMAIL` can mail real users. A "capture everything" verb would produce a store that is wrong in ~4 of 17 entries, silently. So the verb must **force disposition** rather than guess. ## Proposal ``` cast capture <org>/<repo> --env <env> [--state <dir>] [--instance <name>] ``` 1. Read the required secret names from the manifest's env templates for `<env>` (the `${NAME}` refs — the manifest already declares exactly this set). 2. Read live env values from the target instance (`read:sensitive`). 3. Classify every required name: - **captured** — found live, value taken - **generated** — the manifest declares it as provider-generated → write the literal `pending-coolify-generated`, never the live value - **missing** — required by the template, absent live → **refuse**, don't write an empty - **overridden** — value supplied by the operator on the command line, for the cases where the source value must not be carried over 4. Print a **plan of names and provenance — never values** — and require confirmation. 5. On confirm, encrypt to the env's recipient and write `secrets/<repo>.<env>.env.age`. Plaintext never touches disk outside a tmpfs, and never touches stdout. Marking a name as *generated* should be a manifest property, not a flag the operator has to remember — the manifest already knows `DATABASE_URL` comes from a Coolify-created Postgres. ## Acceptance - `cast capture heavy-duty/incubator --env prod` produces a store containing exactly the names the manifest requires — no more, no fewer. - Provider-generated names are placeholders, never carried over from the source box. - A name required by the template but absent from the source refuses the run. - No secret value is ever written to stdout, to a non-tmpfs path, or left behind on exit. ## Note on urgency Deliberately **not** urgent. This verb writes the prod secret store; new code whose bugs land there, on a migration's critical path, is a worse trade than one attended manual pass. File now, build when it isn't load-bearing.
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#15
No description provided.