diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index a783275..5a4f192 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -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" ? ( <> - diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx index 9185286..90a72f4 100644 --- a/app/src/components/detail/ServiceFields.tsx +++ b/app/src/components/detail/ServiceFields.tsx @@ -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) { /> - diff --git a/app/src/components/detail/fields.tsx b/app/src/components/detail/fields.tsx index 672a334..99cf7ae 100644 --- a/app/src/components/detail/fields.tsx +++ b/app/src/components/detail/fields.tsx @@ -71,6 +71,12 @@ export function useEnvSecrets(initial: Record) {
+

+ Use ${"{port}"},{" "} + ${"{data_dir}"},{" "} + ${"{name}"} for castle's computed values, + and ${"{secret:NAME}"} for secrets. +

{Object.entries(env).map(([key, val]) => (
diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 6c52244..67efa1e 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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) diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index db310f8..b13316b 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -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) diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 0de0903..83b56d3 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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: /)") 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 * * *)") diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 6d0621f..9bb8b20 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index d93ce99..28e5fd0 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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) diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index a3a1920..cbc5ca5 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 _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): diff --git a/docs/design.md b/docs/design.md index f46620e..e8021db 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 (`_PORT`, `_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 - `_DATA_DIR=$CASTLE_DATA_DIR/` and `_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) diff --git a/docs/findings-lakehouse.md b/docs/findings-lakehouse.md index 08f7dc9..8ae53c7 100644 --- a/docs/findings-lakehouse.md +++ b/docs/findings-lakehouse.md @@ -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 `_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 `_PORT`/`_DATA_DIR` the same way +(scaffolded by `castle program create`). ### #3 — `castle deploy` regenerates the Caddyfile but never reloads Caddy (High, bug) diff --git a/docs/registry.md b/docs/registry.md index 1efcb7f..92c0de3 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -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/ 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/` (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 `_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/`) to the env var its program reads, in +`defaults.env`. Systemd unit/timer paths are fixed by systemd's user-unit +convention. ## Manifest models diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 3651c8d..3d7afec 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -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 `_DATA_DIR` env var — -it never hardcodes it. Use the `data_dir` setting from your config. +The service receives this path via its `_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