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.
4.6 KiB
Findings — adopting lakehouse as a castle service
A completeness/UX test: take an existing daemon (/data/repos/lakehouse, a
FastAPI lakehoused server on port 8420 that bundles its own SPA, normally
started by a bespoke lakehouse start) and bring it fully under castle —
systemd-managed, health-checked, proxied, on the dashboard.
What worked cleanly
castle addauto-detectedbehavior: daemon+stack: python-fastapifrom the pyproject (fastapi/uvicorn in deps).- Adding the service via the config API (
PUT /config/services/lakehouse) validated and persisted correctly. castle deploy lakehouseproduced a correct systemd unit, registry entry, and Caddy route, and auto-installed the editable package.castle service enable lakehousestarted it and enabled it on boot.- Health probe (direct
127.0.0.1:8420) and dashboard data (program / service / deployment /active) were all correct.
Gaps found
#1 — No CLI/app path to create a service from an adopted daemon (High)
castle add writes a daemon-behavior program, but there is no command to
turn it into a running service (port, health, proxy, systemd). We had to
hand the service block to the config API. Worse: castle install <daemon> on a
program with no service silently falls through to the tool install path
(uv tool install) instead of running it — a dead end.
Fix: castle expose <program> [--port --health --path] to scaffold a
service entry from an existing program. (App needs an equivalent "add service"
flow — tracked separately.)
#2 — castle can't actually configure an adopted program's port / data dir (Medium)
The generated unit injects castle's convention vars LAKEHOUSE_PORT=8420 and
LAKEHOUSE_DATA_DIR=…, but lakehoused reads LAKEHOUSED_DAEMON_PORT and
its own storage paths — it ignores both. The port "worked" only because 8420 is
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.
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)
Deploy wrote the new /lakehouse route to the Caddyfile on disk but didn't
reload the running gateway, so every /lakehouse/* request fell through to the
castle-app catch-all and returned the dashboard's index.html (a misleading
200). castle service enable doesn't reload either, and the deploy output
never mentions castle gateway reload.
Fix: castle deploy (and service enable) reload the gateway when proxy
routes changed.
#4 — Path-prefix proxying breaks root-based SPAs (Medium, architectural)
lakehoused's bundled webapp is built with Vite base: "/" — its index.html
references assets at absolute /assets/…. Proxied at /lakehouse/, the browser
requests /assets/… (no prefix), which hits the castle-app catch-all → the UI
can't boot. The API works through the gateway; the UI only works directly at
:8420/.
Two distinct cases, because Vite bakes base in at build time (a runtime
env in the unit is too late to rebase a pre-built bundle):
- Case A — frontends castle builds (the
react-vitestack, served byfile_server). Castle runspnpm buildand knows the serve prefix, so it can pass--base=/<prefix>/automatically. Todaypower-graph-apponly works because its base was hand-set to match. Fix: derive the build--basefrom the serve path prefix. - Case B — adopted daemons that self-serve a root SPA (lakehouse). Castle
never runs their build, so it can't pass
--base. The clean answer is host-based routing (lakehouse.civil.lan → :8420) sobase: "/"stays valid — also a generally useful castle capability. Fix: support proxy-by-hostname in the Caddyfile generator + manifest.
Nits
- Redundant editable reinstall:
castle deployinstalls the package, thencastle install/activate mayuv tool installit again. - Entry-point discovery: the service's
run.programhad to belakehoused, notlakehouse— the package ships two console scripts and castle gives no hint which is the server. Surfacing a package's[project.scripts]would help.