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

@@ -38,7 +38,6 @@ export function CreateDeploymentForm({
const [runner, setRunner] = useState(prefill?.runner ?? "python")
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [port, setPort] = useState("")
const [portEnv, setPortEnv] = useState("")
const [health, setHealth] = useState("/health")
const [path, setPath] = useState("")
const [host, setHost] = useState("")
@@ -71,7 +70,7 @@ export function CreateDeploymentForm({
if (port) {
base.expose = {
http: {
internal: { port: parseInt(port, 10), ...(portEnv ? { port_env: portEnv } : {}) },
internal: { port: parseInt(port, 10) },
...(health ? { health_path: health } : {}),
},
}
@@ -160,7 +159,6 @@ export function CreateDeploymentForm({
{kind === "service" ? (
<>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Port env" value={portEnv} onChange={setPortEnv} width="w-64" mono placeholder="(custom port var, optional)" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<TextField label="Proxy path" value={path} onChange={setPath} width="w-48" mono placeholder={`/${name || "name"}`} />
<TextField label="Proxy host" value={host} onChange={setHost} mono placeholder="my-service.lan (optional)" />

View File

@@ -28,7 +28,6 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
)
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
const [portEnv, setPortEnv] = useState((internal.port_env as string) ?? "")
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "")
const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "")
@@ -55,10 +54,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
if (port) {
config.expose = {
http: {
internal: {
port: parseInt(port, 10),
...(portEnv ? { port_env: portEnv } : {}),
},
internal: { port: parseInt(port, 10) },
...(health ? { health_path: health } : {}),
},
}
@@ -101,14 +97,6 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/>
</Field>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField
label="Port env"
value={portEnv}
onChange={setPortEnv}
width="w-64"
mono
placeholder="(only if the program reads a custom var)"
/>
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono placeholder="/health" />
<TextField label="Proxy path" value={proxyPath} onChange={setProxyPath} width="w-48" mono placeholder="/my-service" />
<TextField label="Proxy host" value={proxyHost} onChange={setProxyHost} mono placeholder="my-service.lan" />

View File

@@ -71,6 +71,12 @@ export function useEnvSecrets(initial: Record<string, string>) {
<div className="space-y-4">
<Field label="Environment">
<div className="space-y-2">
<p className="text-xs text-[var(--muted)]">
Use <code className="font-mono">${"{port}"}</code>,{" "}
<code className="font-mono">${"{data_dir}"}</code>,{" "}
<code className="font-mono">${"{name}"}</code> for castle's computed values,
and <code className="font-mono">${"{secret:NAME}"}</code> for secrets.
</p>
{Object.entries(env).map(([key, val]) => (
<div key={key} className="flex items-center gap-2">
<input value={key} readOnly className={`w-56 ${INPUT} text-xs text-[var(--muted)]`} />

View File

@@ -8,6 +8,7 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import (
CaddySpec,
DefaultsSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -92,6 +93,7 @@ def run_create(args: argparse.Namespace) -> int:
behavior=behavior,
)
if behavior == "daemon":
prefix = name.replace("-", "_").upper()
config.services[name] = ServiceSpec(
id=name,
program=name,
@@ -104,6 +106,11 @@ def run_create(args: argparse.Namespace) -> int:
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
# computed port/data dir to those vars (explicit, no hidden injection).
defaults=DefaultsSpec(
env={f"{prefix}_PORT": "${port}", f"{prefix}_DATA_DIR": "${data_dir}"}
),
)
save_config(config)

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)

View File

@@ -77,12 +77,17 @@ def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p.add_argument("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)")
p.add_argument("--runner", choices=["python", "command"], default="python")
p.add_argument(
"--env",
action="append",
metavar="KEY=VALUE",
help="Env var for the program (repeatable). Use ${port}/${data_dir}/${name} placeholders.",
)
if kind == "service":
p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument("--path", help="Gateway proxy prefix (default: /<name>)")
p.add_argument("--host", help="Route by hostname instead of a path prefix")
p.add_argument("--port-env", help="Env var the program reads for its port")
p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route")
else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)")

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)

View File

@@ -157,9 +157,12 @@ def _reload_gateway(messages: list[str]) -> None:
# ---------------------------------------------------------------------------
def _env_prefix(name: str) -> str:
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _env_context(name: str, config_key: str, port: int | None) -> dict[str, str]:
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}."""
ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)}
if port is not None:
ctx["port"] = str(port)
return ctx
def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> str | None:
@@ -176,41 +179,26 @@ def _build_deployed_service(
) -> Deployment:
"""Build a Deployment from a ServiceSpec."""
run = svc.run
# Env prefix and data dir are keyed by the program (component) the service
# runs, not the service name — that's the prefix the program's settings read
# (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR).
# Falls back to the service name when no component is referenced.
# The data-dir placeholder is keyed by the program the service runs, not the
# service name (e.g. job `protonmail-sync` runs program `protonmail` →
# /data/castle/protonmail). Falls back to the service name.
config_key = svc.program or name
prefix = _env_prefix(config_key)
env: dict[str, str] = {}
# Data dir convention (for managed services). Skipped for container runners:
# they use volume mounts, not a data-dir env var, and the injected name can
# collide with an image's own env namespace (e.g. neo4j claims NEO4J_*).
managed = run.runner != "remote"
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
if managed and run.runner != "container":
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
# Port convention (if exposed)
port = None
health_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
env[f"{prefix}_PORT"] = str(port)
# If the program reads a non-convention port var, set that too so castle
# actually controls the bind (e.g. adopted lakehoused → LAKEHOUSED_DAEMON_PORT).
if svc.expose.http.internal.port_env:
env[svc.expose.http.internal.port_env] = str(port)
health_path = svc.expose.http.health_path
# Merge defaults.env (overrides conventions)
if svc.defaults and svc.defaults.env:
env.update(svc.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Env is exactly what's declared in defaults.env — no hidden convention
# injection. ${port}/${data_dir}/${name} let the program's own env var names
# map to castle's computed values without hardcoding them.
env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
env = resolve_env_vars(env, _env_context(name, config_key, port))
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.program, messages)
@@ -260,18 +248,11 @@ def _build_deployed_job(
) -> Deployment:
"""Build a Deployment from a JobSpec."""
run = job.run
# Keyed by the program (component) the job runs, not the job name — see
# _build_deployed_service for rationale. Falls back to the job name.
# ${data_dir} is keyed by the program the job runs, not the job name — see
# _build_deployed_service. Falls back to the job name.
config_key = job.program or name
prefix = _env_prefix(config_key)
env: dict[str, str] = {}
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
if job.defaults and job.defaults.env:
env.update(job.defaults.env)
env = resolve_env_vars(env)
env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
env = resolve_env_vars(env, _env_context(name, config_key, None))
_ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(name, run, env, messages)

View File

@@ -111,12 +111,6 @@ class HttpInternal(BaseModel):
host: str = "127.0.0.1"
port: int = Field(ge=1, le=65535)
unix_socket: str | None = None
# Env var the program actually reads for its bind port. Castle's convention
# injects <PREFIX>_PORT, but an adopted program may read a different name
# (e.g. lakehoused reads LAKEHOUSED_DAEMON_PORT). When set, deploy also sets
# this var to `port`, so castle genuinely drives the bind instead of relying
# on the program's default happening to match.
port_env: str | None = None
class HttpPublic(BaseModel):

View File

@@ -195,9 +195,9 @@ Services and jobs can reference a program via `program:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
Convention-based env vars (`<PREFIX>_PORT`, `<PREFIX>_DATA_DIR`) are
generated automatically during deploy. Only non-convention values need
`defaults.env`.
A service's env is exactly its `defaults.env` — castle injects nothing
implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
placeholders, which deploy resolves into the registry's flat `env`.
**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle deploy`) — Node config:
@@ -222,8 +222,8 @@ deployed:
```
The node config says what's deployed *here* and with what concrete
values. `castle deploy` reads the spec from the repo, generates
convention-based env vars, resolves secrets, resolves binary paths,
values. `castle deploy` reads the spec from the repo, resolves the
`defaults.env` placeholders and secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly.
@@ -521,9 +521,9 @@ What exists today:
`$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
Caddyfile generated from registry with fully resolved paths. No repo references
in runtime artifacts.
- **Convention-based env generation** — `castle deploy` auto-generates
`<PREFIX>_DATA_DIR=$CASTLE_DATA_DIR/<name>` and `<PREFIX>_PORT` from
the manifest. Only non-convention values need `defaults.env`.
- **Explicit env with placeholders** — a deployment's env is exactly its
`defaults.env`; `castle deploy` resolves `${port}`/`${data_dir}`/`${name}`/
`${secret:…}` into concrete values. No hidden convention injection.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional
castle.yaml fallback for non-deployed programs)

View File

@@ -40,10 +40,13 @@ the daemon's built-in default and we declared the same value. Castle's
convention assumes a castle-aware program; an adopted one doesn't read
`<PREFIX>_PORT`.
**Fix:** let a service declare which env var carries the port, e.g.
`expose.http.port_env: LAKEHOUSED_DAEMON_PORT`, so castle sets *that* to the
configured port and genuinely drives the bind. (`defaults.env` is the manual
workaround.)
**Fixed** (superseding the interim `port_env` field): auto-injection of the
convention vars was dropped entirely. A service's env is now exactly its
`defaults.env`, with `${port}`/`${data_dir}`/`${name}` placeholders for castle's
computed values. lakehouse maps just what it reads —
`LAKEHOUSED_DAEMON_PORT: ${port}` — and keeps its own data root, with no dead
vars. Castle-native services map their `<PREFIX>_PORT`/`_DATA_DIR` the same way
(scaffolded by `castle program create`).
### #3 — `castle deploy` regenerates the Caddyfile but never reloads Caddy (High, bug)

View File

@@ -255,17 +255,36 @@ manage:
exec_reload: "caddy reload ..."
```
### `defaults` — Default environment
### `defaults` — Environment
`defaults.env` is the **single, explicit source** of the env a service/job runs
with — what you write here is exactly what lands in the systemd unit. Castle
does **not** inject hidden convention vars; whatever env var your program reads
for its port, data dir, etc., you map here.
```yaml
expose: { http: { internal: { port: 9001 }, health_path: /health } }
defaults:
env:
MY_SERVICE_PORT: ${port} # the program's own port var ← expose.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/<name>
CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY}
```
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`.
Never store secrets in castle.yaml or project directories.
Values may contain placeholders that castle resolves at deploy:
| Placeholder | Expands to |
|-------------|------------|
| `${port}` | the service's `expose.http.internal.port` (so it can't drift) |
| `${data_dir}` | `$CASTLE_DATA_DIR/<program-or-name>` (the dedicated data volume) |
| `${name}` | the deployment name |
| `${secret:NAME}` | the contents of `~/.castle/secrets/NAME` |
Hardcode the values instead if you prefer; the placeholders just save you from
repeating castle's computed paths/ports. `castle program create` scaffolds the
`${port}`/`${data_dir}` lines for new services. Never store secrets in
castle.yaml — use `${secret:…}`.
## Job blocks
@@ -431,9 +450,10 @@ variable (both expand `~` and resolve relative paths):
Defined in `core/src/castle_core/config.py`: `CASTLE_HOME` (with derived
`CODE_DIR`, `SECRETS_DIR`, `SPECS_DIR`, `CONTENT_DIR`) and the independent
`DATA_DIR` (`CASTLE_DATA_DIR`). `castle deploy` passes each service its data
path via the generated `<PREFIX>_DATA_DIR` env var. Systemd unit/timer paths are
fixed by systemd's user-unit convention.
`DATA_DIR` (`CASTLE_DATA_DIR`). A service reaches its data path by mapping
`${data_dir}` (= `$CASTLE_DATA_DIR/<name>`) to the env var its program reads, in
`defaults.env`. Systemd unit/timer paths are fixed by systemd's user-unit
convention.
## Manifest models

View File

@@ -129,16 +129,21 @@ services:
systemd: {}
```
Convention-based env vars (`MY_SERVICE_DATA_DIR`, `MY_SERVICE_PORT`) are
generated automatically by `castle deploy`. Only non-convention values
need `defaults.env`:
The env a service runs with is exactly what's in `defaults.env` — castle injects
nothing implicitly. Map the vars your settings read (above, `env_prefix:
"MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed
values with the `${port}`/`${data_dir}` placeholders:
```yaml
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
```
`castle program create` scaffolds the `${port}`/`${data_dir}` lines for you.
## Application entry point
```python
@@ -305,8 +310,9 @@ $CASTLE_DATA_DIR/my-service/ # default /data/castle/my-service/
└── item-name.meta.json
```
The service receives this path via the generated `<PREFIX>_DATA_DIR` env var
it never hardcodes it. Use the `data_dir` setting from your config.
The service receives this path via its `<PREFIX>_DATA_DIR` env var, which
`defaults.env` maps from `${data_dir}` — it never hardcodes it. Use the
`data_dir` setting from your config.
```python
# storage.py