feat: castle UX fixes from the lakehouse adoption test

Driven by adopting lakehouse (an existing daemon that bundles its own SPA) —
see docs/findings-lakehouse.md.

#3 deploy reloads the gateway. castle deploy regenerated the Caddyfile but
   left the running Caddy on the old config, so new proxy routes were silently
   dead. Deploy now reloads the gateway when it's running.

#1 castle expose <program>. Turns an adopted program into a service
   (run/port/health/proxy/systemd) in one command — the missing
   daemon-to-service step. Flags: --port --health --path --run --port-env
   --host --no-proxy.

#2 port_env mapping. A service can declare expose.http.internal.port_env so
   castle sets the env var the program actually reads (e.g. lakehoused reads
   LAKEHOUSED_DAEMON_PORT, not castle's convention LAKEHOUSE_PORT). Castle now
   genuinely drives an adopted daemon's bind port.

#4a auto-base for react-vite. The build passes VITE_BASE = the gateway serve
   prefix (/<name>/, or / for castle-app); vite.config reads it. A castle-built
   frontend now works at its subpath with no hand-tuned base. castle-app's
   vite.config updated as the reference.

#4b host-based routing. proxy.caddy.host routes a whole hostname to the backend
   root via a host matcher inside the :9000 site, so a root-based SPA (base="/")
   serves unchanged — the fix for proxying an app castle can't rebuild. Caddyfile
   now emits 'auto_https off' (HTTP-only gateway on a non-standard port).

Nit: activate skips the editable reinstall when the tool is already on PATH.

Tests: core 94, cli 24, api 52; ruff + app build clean. Verified live: lakehouse
runs under systemd, API at /lakehouse, full UI (SPA boots) via host routing.
This commit is contained in:
2026-06-14 13:16:34 -07:00
parent 203f5212e6
commit 4840cbe72a
12 changed files with 350 additions and 8 deletions

View File

@@ -121,10 +121,37 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
# Reload the gateway so the freshly written Caddyfile takes effect. Without
# this, new/changed proxy routes sit on disk but the running Caddy keeps the
# old config (a deployed service's route is silently dead until reload).
_reload_gateway(result.messages)
result.registry = registry
return result
# Gateway service name in the registry → its systemd unit (castle-castle-gateway).
_GATEWAY_NAME = "castle-gateway"
def _reload_gateway(messages: list[str]) -> None:
"""Reload Caddy if the gateway is running, so new routes take effect."""
gw_unit = unit_name(_GATEWAY_NAME)
active = subprocess.run(
["systemctl", "--user", "is-active", gw_unit],
capture_output=True,
text=True,
)
if active.stdout.strip() != "active":
messages.append("Gateway not running — skipped reload (start it with 'castle gateway start').")
return
result = subprocess.run(["systemctl", "--user", "reload", gw_unit], capture_output=True, text=True)
if result.returncode == 0:
messages.append("Gateway reloaded.")
else:
messages.append(f"Warning: gateway reload failed: {result.stderr.strip()}")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -172,6 +199,10 @@ def _build_deployed_service(
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)
@@ -187,10 +218,18 @@ def _build_deployed_service(
# Build run_cmd
run_cmd = _build_run_cmd(name, run, env, messages)
# Proxy path
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
# dedicated host site block, so a root-based app serves unchanged).
proxy_path = None
proxy_host = None
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
caddy = svc.proxy.caddy
proxy_host = caddy.host
if caddy.path_prefix:
proxy_path = caddy.path_prefix
elif not caddy.host:
# No explicit path and no host → default to /<name>.
proxy_path = f"/{name}"
# Resolve stack from referenced program
stack = None
@@ -210,6 +249,7 @@ def _build_deployed_service(
port=port,
health_path=health_path,
proxy_path=proxy_path,
proxy_host=proxy_host,
base_url=base_url,
managed=managed,
)

View File

@@ -20,11 +20,35 @@ def generate_caddyfile_from_registry(
If remote_registries is provided, cross-node routes are added for
remote services whose proxy_path doesn't conflict with local ones.
"""
lines = [f":{registry.node.gateway_port} {{"]
gw_port = registry.node.gateway_port
# Global options: the gateway is an internal HTTP-only reverse proxy on a
# non-standard port. Disable automatic HTTPS so named hosts don't pull the
# listener into TLS or try to bind :80/:443 (which fails for a user service).
lines = ["{", " auto_https off", "}", ""]
lines.append(f":{gw_port} {{")
# Track local proxy paths for precedence
local_paths: set[str] = set()
# Host-based routes: a `host` matcher inside the single :9000 site (NOT a
# separate site block — that would split the listener and flip it to TLS).
# The whole host maps to the backend root, so a root-based SPA (base="/")
# serves unchanged. Emitted first so a host match wins over path routes.
for name, deployed in registry.deployed.items():
if not deployed.proxy_host:
continue
if not deployed.port and not deployed.base_url:
continue
target = deployed.base_url or f"localhost:{deployed.port}"
matcher = f"@host_{name.replace('-', '_')}"
lines.append(f" {matcher} host {deployed.proxy_host}")
lines.append(f" handle {matcher} {{")
lines.append(f" reverse_proxy {target}")
lines.append(" }")
lines.append("")
for name, deployed in registry.deployed.items():
if not deployed.proxy_path:
continue

View File

@@ -142,8 +142,10 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
# Process-backed: daemon, self-serving frontend, or job.
if name in config.services or name in config.jobs:
# Ensure the program's binary is on PATH first (python programs).
if comp is not None and (comp.stack or comp.commands):
# Ensure the program's binary is on PATH first (python programs). Skip the
# editable reinstall if it's already there — `castle deploy` installs it,
# so re-running it on every activate is wasted work.
if comp is not None and (comp.stack or comp.commands) and not _on_path(name):
res = await run_action("install", name, comp, root)
if res.status != "ok":
return res

View File

@@ -111,6 +111,12 @@ 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):
@@ -132,6 +138,10 @@ class ExposeSpec(BaseModel):
class CaddySpec(BaseModel):
enable: bool = True
path_prefix: str | None = None
# Route by hostname instead of (or alongside) a path prefix. The whole host
# maps to the backend root, so an app built with base="/" works unchanged —
# the fix for proxying a root-based SPA the gateway can't rebuild.
host: str | None = None
extra_snippets: list[str] = Field(default_factory=list)

View File

@@ -40,6 +40,7 @@ class Deployment:
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
proxy_host: str | None = None
base_url: str | None = None
schedule: str | None = None
managed: bool = False
@@ -102,6 +103,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
proxy_host=comp_data.get("proxy_host"),
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
@@ -146,6 +148,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["health_path"] = comp.health_path
if comp.proxy_path:
entry["proxy_path"] = comp.proxy_path
if comp.proxy_host:
entry["proxy_host"] = comp.proxy_host
if comp.base_url:
entry["base_url"] = comp.base_url
if comp.schedule:

View File

@@ -45,12 +45,15 @@ def _build_env() -> dict[str, str]:
return env
async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
async def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]:
"""Run a subprocess and return (returncode, combined output)."""
run_env = _build_env()
if env:
run_env.update(env)
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
env=_build_env(),
env=run_env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
@@ -58,6 +61,16 @@ async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
return proc.returncode or 0, (stdout or b"").decode()
def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against.
Matches the Caddyfile serve prefix: castle-app is the root app ('/'); every
other static frontend mounts at '/<name>/'. Exposed to the build as VITE_BASE
so the bundle's absolute asset URLs resolve at the gateway subpath (the
vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
return "/" if name == "castle-app" else f"/{name}/"
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
"""Resolve source directory, raising ValueError if absent."""
if not comp.source:
@@ -181,7 +194,9 @@ class ReactViteHandler(StackHandler):
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "build"], src)
# Build against the gateway serve prefix so absolute asset URLs resolve at
# /<name>/ (vite.config reads VITE_BASE). Removes the hand-tuned-base footgun.
rc, output = await _run(["pnpm", "build"], src, env={"VITE_BASE": _vite_base(name)})
return ActionResult(
program=name, action="build", status="ok" if rc == 0 else "error", output=output
)

View File

@@ -57,6 +57,32 @@ class TestCaddyfileFromRegistry:
assert "handle_path /test-svc/*" in caddyfile
assert "reverse_proxy localhost:19000" in caddyfile
def test_disables_auto_https(self) -> None:
"""The gateway is HTTP-only; auto_https must be off so named hosts don't
flip the listener to TLS or try to bind :80."""
caddyfile = generate_caddyfile_from_registry(_make_registry())
assert "auto_https off" in caddyfile
def test_host_route_uses_matcher_in_main_site(self) -> None:
"""A proxy_host becomes a host matcher inside the :9000 site (not a
separate site block, which would split the listener into TLS)."""
registry = _make_registry(
deployed={
"lake": Deployment(
runner="python",
run_cmd=["lake"],
port=8420,
proxy_host="lake.example.lan",
),
}
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "@host_lake host lake.example.lan" in caddyfile
assert "handle @host_lake {" in caddyfile
assert "reverse_proxy localhost:8420" in caddyfile
# No separate hostname site block on the gateway port.
assert "lake.example.lan:9000 {" not in caddyfile
def test_skips_non_proxied(self) -> None:
"""Components without proxy_path are not in Caddyfile."""
registry = _make_registry(