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 [runner, setRunner] = useState(prefill?.runner ?? "python")
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "") const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [port, setPort] = useState("") const [port, setPort] = useState("")
const [portEnv, setPortEnv] = useState("")
const [health, setHealth] = useState("/health") const [health, setHealth] = useState("/health")
const [path, setPath] = useState("") const [path, setPath] = useState("")
const [host, setHost] = useState("") const [host, setHost] = useState("")
@@ -71,7 +70,7 @@ export function CreateDeploymentForm({
if (port) { if (port) {
base.expose = { base.expose = {
http: { http: {
internal: { port: parseInt(port, 10), ...(portEnv ? { port_env: portEnv } : {}) }, internal: { port: parseInt(port, 10) },
...(health ? { health_path: health } : {}), ...(health ? { health_path: health } : {}),
}, },
} }
@@ -160,7 +159,6 @@ export function CreateDeploymentForm({
{kind === "service" ? ( {kind === "service" ? (
<> <>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" /> <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="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 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)" /> <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(" "), (run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
) )
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "") 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 [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "") const [proxyPath, setProxyPath] = useState((caddy.path_prefix as string) ?? "")
const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "") const [proxyHost, setProxyHost] = useState((caddy.host as string) ?? "")
@@ -55,10 +54,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
if (port) { if (port) {
config.expose = { config.expose = {
http: { http: {
internal: { internal: { port: parseInt(port, 10) },
port: parseInt(port, 10),
...(portEnv ? { port_env: portEnv } : {}),
},
...(health ? { health_path: health } : {}), ...(health ? { health_path: health } : {}),
}, },
} }
@@ -101,14 +97,6 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/> />
</Field> </Field>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" /> <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="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 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" /> <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"> <div className="space-y-4">
<Field label="Environment"> <Field label="Environment">
<div className="space-y-2"> <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]) => ( {Object.entries(env).map(([key, val]) => (
<div key={key} className="flex items-center gap-2"> <div key={key} className="flex items-center gap-2">
<input value={key} readOnly className={`w-56 ${INPUT} text-xs text-[var(--muted)]`} /> <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.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import ( from castle_cli.manifest import (
CaddySpec, CaddySpec,
DefaultsSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
@@ -92,6 +93,7 @@ def run_create(args: argparse.Namespace) -> int:
behavior=behavior, behavior=behavior,
) )
if behavior == "daemon": if behavior == "daemon":
prefix = name.replace("-", "_").upper()
config.services[name] = ServiceSpec( config.services[name] = ServiceSpec(
id=name, id=name,
program=name, program=name,
@@ -104,6 +106,11 @@ def run_create(args: argparse.Namespace) -> int:
), ),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")), proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
manage=ManageSpec(systemd=SystemdSpec()), 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) save_config(config)

View File

@@ -12,6 +12,7 @@ import argparse
from castle_cli.config import load_config, save_config from castle_cli.config import load_config, save_config
from castle_cli.manifest import ( from castle_cli.manifest import (
CaddySpec, CaddySpec,
DefaultsSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, 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: def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command": if runner == "command":
return RunCommand(runner="command", argv=target.split() or [name]) 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: if args.port is not None:
expose = ExposeSpec( expose = ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=args.port, port_env=args.port_env), internal=HttpInternal(port=args.port),
health_path=args.health, health_path=args.health,
) )
) )
@@ -76,13 +88,14 @@ def run_service_create(args: argparse.Namespace) -> int:
expose=expose, expose=expose,
proxy=proxy, proxy=proxy,
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
) )
save_config(config) save_config(config)
print(f"Created service '{name}'.") print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})") print(f" runs: {args.runner} ({args.run or args.program or name})")
if expose: 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 and proxy.caddy:
if proxy.caddy.path_prefix: if proxy.caddy.path_prefix:
print(f" proxy: {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, run=run,
schedule=args.schedule, schedule=args.schedule,
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
defaults=_defaults(args.env),
) )
save_config(config) 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("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)") 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("--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": if kind == "service":
p.add_argument("--port", type=int, help="HTTP port") p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)") p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument("--path", help="Gateway proxy prefix (default: /<name>)") 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("--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") p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route")
else: else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)") 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]: def resolve_env_vars(
"""Resolve ${secret:NAME} references in env values.""" 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 = {} resolved = {}
for key, value in env.items(): for key, value in env.items():
def replace_var(match: re.Match[str]) -> str: def replace_var(match: re.Match[str]) -> str:
ref = match.group(1) ref = match.group(1)
if ref.startswith("secret:"): if ref.startswith("secret:"):
secret_name = ref[7:] return _read_secret(ref[7:])
return _read_secret(secret_name) if ref in context:
return context[ref]
return match.group(0) return match.group(0)
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value) 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: def _env_context(name: str, config_key: str, port: int | None) -> dict[str, str]:
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT.""" """Placeholder values for defaults.env: ${name}/${data_dir}/${port}."""
return name.replace("-", "_").upper() 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: def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> str | None:
@@ -176,41 +179,26 @@ def _build_deployed_service(
) -> Deployment: ) -> Deployment:
"""Build a Deployment from a ServiceSpec.""" """Build a Deployment from a ServiceSpec."""
run = svc.run run = svc.run
# Env prefix and data dir are keyed by the program (component) the service # The data-dir placeholder is keyed by the program the service runs, not the
# runs, not the service name — that's the prefix the program's settings read # service name (e.g. job `protonmail-sync` runs program `protonmail` →
# (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR). # /data/castle/protonmail). Falls back to the service name.
# Falls back to the service name when no component is referenced.
config_key = svc.program or 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" managed = run.runner != "remote"
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False managed = False
if managed and run.runner != "container":
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / config_key)
# Port convention (if exposed)
port = None port = None
health_path = None health_path = None
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port 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 health_path = svc.expose.http.health_path
# Merge defaults.env (overrides conventions) # Env is exactly what's declared in defaults.env — no hidden convention
if svc.defaults and svc.defaults.env: # injection. ${port}/${data_dir}/${name} let the program's own env var names
env.update(svc.defaults.env) # map to castle's computed values without hardcoding them.
env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
# Resolve secrets env = resolve_env_vars(env, _env_context(name, config_key, port))
env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary # Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.program, messages) _ensure_python_tool(config, svc.program, messages)
@@ -260,18 +248,11 @@ def _build_deployed_job(
) -> Deployment: ) -> Deployment:
"""Build a Deployment from a JobSpec.""" """Build a Deployment from a JobSpec."""
run = job.run run = job.run
# Keyed by the program (component) the job runs, not the job name — see # ${data_dir} is keyed by the program the job runs, not the job name — see
# _build_deployed_service for rationale. Falls back to the job name. # _build_deployed_service. Falls back to the job name.
config_key = job.program or name config_key = job.program or name
prefix = _env_prefix(config_key) env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
env: dict[str, str] = {} env = resolve_env_vars(env, _env_context(name, config_key, None))
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)
_ensure_python_tool(config, job.program, messages) _ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(name, run, env, 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" host: str = "127.0.0.1"
port: int = Field(ge=1, le=65535) port: int = Field(ge=1, le=65535)
unix_socket: str | None = None 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): 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 fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software). (e.g., `castle-gateway` runs Caddy — not our software).
Convention-based env vars (`<PREFIX>_PORT`, `<PREFIX>_DATA_DIR`) are A service's env is exactly its `defaults.env` — castle injects nothing
generated automatically during deploy. Only non-convention values need implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
`defaults.env`. 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: **`$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 The node config says what's deployed *here* and with what concrete
values. `castle deploy` reads the spec from the repo, generates values. `castle deploy` reads the spec from the repo, resolves the
convention-based env vars, resolves secrets, resolves binary paths, `defaults.env` placeholders and secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly. 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 `$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
Caddyfile generated from registry with fully resolved paths. No repo references Caddyfile generated from registry with fully resolved paths. No repo references
in runtime artifacts. in runtime artifacts.
- **Convention-based env generation** — `castle deploy` auto-generates - **Explicit env with placeholders** — a deployment's env is exactly its
`<PREFIX>_DATA_DIR=$CASTLE_DATA_DIR/<name>` and `<PREFIX>_PORT` from `defaults.env`; `castle deploy` resolves `${port}`/`${data_dir}`/`${name}`/
the manifest. Only non-convention values need `defaults.env`. `${secret:…}` into concrete values. No hidden convention injection.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry - **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional - **API** — `castle-api` on port 9020, reads from registry (optional
castle.yaml fallback for non-deployed programs) 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 convention assumes a castle-aware program; an adopted one doesn't read
`<PREFIX>_PORT`. `<PREFIX>_PORT`.
**Fix:** let a service declare which env var carries the port, e.g. **Fixed** (superseding the interim `port_env` field): auto-injection of the
`expose.http.port_env: LAKEHOUSED_DAEMON_PORT`, so castle sets *that* to the convention vars was dropped entirely. A service's env is now exactly its
configured port and genuinely drives the bind. (`defaults.env` is the manual `defaults.env`, with `${port}`/`${data_dir}`/`${name}` placeholders for castle's
workaround.) 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) ### #3 — `castle deploy` regenerates the Caddyfile but never reloads Caddy (High, bug)

View File

@@ -255,17 +255,36 @@ manage:
exec_reload: "caddy reload ..." 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 ```yaml
expose: { http: { internal: { port: 9001 }, health_path: /health } }
defaults: defaults:
env: 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 CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY} API_KEY: ${secret:MY_API_KEY}
``` ```
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`. Values may contain placeholders that castle resolves at deploy:
Never store secrets in castle.yaml or project directories.
| 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 ## 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 Defined in `core/src/castle_core/config.py`: `CASTLE_HOME` (with derived
`CODE_DIR`, `SECRETS_DIR`, `SPECS_DIR`, `CONTENT_DIR`) and the independent `CODE_DIR`, `SECRETS_DIR`, `SPECS_DIR`, `CONTENT_DIR`) and the independent
`DATA_DIR` (`CASTLE_DATA_DIR`). `castle deploy` passes each service its data `DATA_DIR` (`CASTLE_DATA_DIR`). A service reaches its data path by mapping
path via the generated `<PREFIX>_DATA_DIR` env var. Systemd unit/timer paths are `${data_dir}` (= `$CASTLE_DATA_DIR/<name>`) to the env var its program reads, in
fixed by systemd's user-unit convention. `defaults.env`. Systemd unit/timer paths are fixed by systemd's user-unit
convention.
## Manifest models ## Manifest models

View File

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