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

@@ -12,6 +12,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -25,6 +26,17 @@ from castle_cli.manifest import (
)
def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
"""Parse repeated --env KEY=VALUE into a DefaultsSpec, or None."""
if not env_args:
return None
env: dict[str, str] = {}
for item in env_args:
key, _, value = item.partition("=")
env[key.strip()] = value
return DefaultsSpec(env=env)
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name])
@@ -54,7 +66,7 @@ def run_service_create(args: argparse.Namespace) -> int:
if args.port is not None:
expose = ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=args.port, port_env=args.port_env),
internal=HttpInternal(port=args.port),
health_path=args.health,
)
)
@@ -76,13 +88,14 @@ def run_service_create(args: argparse.Namespace) -> int:
expose=expose,
proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)
print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})")
if expose:
print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
print(f" port: {args.port}")
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
@@ -109,6 +122,7 @@ def run_job_create(args: argparse.Namespace) -> int:
run=run,
schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
)
save_config(config)