refactor(env): explicit defaults.env with placeholders; drop auto-injection

A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.

Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.

- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
  deploy builds env from defaults.env only — no <PREFIX>_* injection, no
  port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
  --port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
  daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
  the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
  real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
  stays in ~/.lakehoused. Verified all services healthy on their ports, dead
  vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
  model.

core 94 / cli 24 / api 52 green; ruff + app build clean.
This commit is contained in:
2026-06-14 17:06:46 -07:00
parent fd562f7468
commit 7314b5cddb
13 changed files with 122 additions and 89 deletions

View File

@@ -141,16 +141,27 @@ class CastleConfig:
}
def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
"""Resolve ${secret:NAME} references in env values."""
def resolve_env_vars(
env: dict[str, str], context: dict[str, str] | None = None
) -> dict[str, str]:
"""Resolve placeholders in env values.
- ``${secret:NAME}`` reads `~/.castle/secrets/NAME`.
- ``${port}`` / ``${data_dir}`` / ``${name}`` (and anything else in
``context``) expand to castle's computed values, so a service maps them to
whatever env var its program reads (e.g. ``MY_PORT: ${port}``) without
hardcoding or castle silently injecting a guessed var name.
"""
context = context or {}
resolved = {}
for key, value in env.items():
def replace_var(match: re.Match[str]) -> str:
ref = match.group(1)
if ref.startswith("secret:"):
secret_name = ref[7:]
return _read_secret(secret_name)
return _read_secret(ref[7:])
if ref in context:
return context[ref]
return match.group(0)
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value)