Add castle secret CLI and fail-loud unresolved-secret apply gate

Writing a secret by hand meant knowing the active backend and its layout;
getting it wrong (a file write on an OpenBao fleet) left the value where the
resolver never reads it, so ${secret:NAME} silently degraded to the literal
<MISSING_SECRET:NAME> placeholder that a service then used as its credential.

- `castle secret {list|set|get|rm}` reads/writes the ACTIVE backend, so there's
  no wrong store to pick. `set NAME` with no value reads a hidden prompt / stdin.
- `castle apply` (and --plan) now refuses to converge any deployment whose
  ${secret:...} refs don't resolve in the active backend: exits non-zero, writes
  nothing, and names each deployment + secret + the `castle secret set` fix.
- New helpers in core/config.py: active_secret_backend(), active_backend_name(),
  secret_refs(). Gate impl: deploy._unresolved_secrets() + ApplyResult.blocked.
- Tests: test_deploy_secret_gate.py, TestSecretRefs, test_secret.py. Docs: AGENTS.md.
This commit is contained in:
2026-07-14 07:17:17 -07:00
parent d152e79cee
commit afc623ec58
9 changed files with 379 additions and 2 deletions

View File

@@ -75,6 +75,12 @@ castle job <list|info|delete|restart|logs> ...
castle tool list [--json] # each tool's executable + description + install state
castle tool info <name> [--json]
# Secrets — read/write the ACTIVE backend (file or openbao); never hand-write the store
castle secret list # names in the active backend
castle secret set <NAME> [VALUE] # VALUE omitted → hidden prompt / stdin
castle secret get <NAME> # print a value
castle secret rm <NAME> [-y] # delete
# Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind ...] [--stack ...] [--json] # all deployments
@@ -271,6 +277,14 @@ secrets:
via a least-privilege `castle-read` token. Only the bootstrap tier stays as
files (`OPENBAO_ROOT_TOKEN`/`OPENBAO_UNSEAL_KEY`, the `cloudflared` creds dir).
**Write secrets with `castle secret set NAME`, never by hand.** It targets the
*active* backend, so a value can't land in a store the resolver never reads — the
failure mode where a file-written secret is silently ignored on an openbao fleet
and `${secret:NAME}` degrades to a literal `<MISSING_SECRET:NAME>` that a service
then uses as its credential. `castle apply` **refuses to converge** (exits
non-zero, writes nothing) any deployment whose `${secret:…}` refs don't resolve in
the active backend, naming the fix.
**Never** put secret *values* in `castle.yaml` or project dirs — use `${secret:…}`.
Roots: **`CASTLE_HOME`** (config/code/artifacts/secrets, default `~/.castle`,
env-only — it *contains* castle.yaml) and **program data** (base of `${data_dir}`,