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)]`} />