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:
@@ -4,6 +4,9 @@ import { defineConfig } from "vite"
|
|||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Castle sets VITE_BASE to the gateway serve prefix at build time so absolute
|
||||||
|
// asset URLs resolve at the subpath (castle-app is the root app → "/").
|
||||||
|
base: process.env.VITE_BASE ?? "/",
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
99
cli/src/castle_cli/commands/expose.py
Normal file
99
cli/src/castle_cli/commands/expose.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""castle expose — turn an existing program into a service.
|
||||||
|
|
||||||
|
`castle add` adopts source as a program; `castle expose` declares how to *run*
|
||||||
|
that program as a long-running systemd service (port, health, proxy). It fills
|
||||||
|
the gap where adopting a daemon left you with a program but no way to run it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from castle_cli.config import load_config, save_config
|
||||||
|
from castle_cli.manifest import (
|
||||||
|
CaddySpec,
|
||||||
|
ExposeSpec,
|
||||||
|
HttpExposeSpec,
|
||||||
|
HttpInternal,
|
||||||
|
ManageSpec,
|
||||||
|
ProxySpec,
|
||||||
|
RunCommand,
|
||||||
|
RunPython,
|
||||||
|
ServiceSpec,
|
||||||
|
SystemdSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_python(program: object) -> bool:
|
||||||
|
"""Whether the program runs as a python console script (uv-installed)."""
|
||||||
|
stack = getattr(program, "stack", None)
|
||||||
|
if stack and stack.startswith("python"):
|
||||||
|
return True
|
||||||
|
source = getattr(program, "source", None)
|
||||||
|
return bool(source and (Path(source) / "pyproject.toml").exists())
|
||||||
|
|
||||||
|
|
||||||
|
def run_expose(args: argparse.Namespace) -> int:
|
||||||
|
"""Create a service entry that runs an existing program."""
|
||||||
|
config = load_config()
|
||||||
|
name = args.name
|
||||||
|
|
||||||
|
program = config.programs.get(name)
|
||||||
|
if program is None:
|
||||||
|
print(f"Error: no program '{name}'. Adopt it first with 'castle add', then expose it.")
|
||||||
|
return 1
|
||||||
|
if name in config.services or name in config.jobs:
|
||||||
|
print(f"Error: '{name}' is already a service or job.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
run_script = args.run or name
|
||||||
|
run = (
|
||||||
|
RunPython(runner="python", program=run_script)
|
||||||
|
if _is_python(program)
|
||||||
|
else RunCommand(runner="command", argv=[run_script])
|
||||||
|
)
|
||||||
|
|
||||||
|
expose = None
|
||||||
|
proxy = None
|
||||||
|
if args.port is not None:
|
||||||
|
expose = ExposeSpec(
|
||||||
|
http=HttpExposeSpec(
|
||||||
|
internal=HttpInternal(port=args.port, port_env=args.port_env),
|
||||||
|
health_path=args.health,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
host = getattr(args, "host", None)
|
||||||
|
if not args.no_proxy:
|
||||||
|
# A path prefix, a hostname, or both. With a host but no explicit
|
||||||
|
# path, route by host only (root-based apps serve unchanged).
|
||||||
|
if args.path:
|
||||||
|
prefix = args.path if args.path.startswith("/") else "/" + args.path
|
||||||
|
elif host:
|
||||||
|
prefix = None
|
||||||
|
else:
|
||||||
|
prefix = f"/{name}"
|
||||||
|
proxy = ProxySpec(caddy=CaddySpec(path_prefix=prefix, host=host))
|
||||||
|
|
||||||
|
config.services[name] = ServiceSpec(
|
||||||
|
id=name,
|
||||||
|
program=name,
|
||||||
|
run=run,
|
||||||
|
expose=expose,
|
||||||
|
proxy=proxy,
|
||||||
|
manage=ManageSpec(systemd=SystemdSpec()),
|
||||||
|
)
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
print(f"Exposed '{name}' as a service.")
|
||||||
|
print(f" run: {run.runner} ({run_script})")
|
||||||
|
if expose:
|
||||||
|
print(f" port: {args.port}" + (f" (env: {args.port_env})" if args.port_env else ""))
|
||||||
|
print(f" health: {args.health}")
|
||||||
|
if proxy and proxy.caddy:
|
||||||
|
if proxy.caddy.path_prefix:
|
||||||
|
print(f" proxy: {proxy.caddy.path_prefix}")
|
||||||
|
if proxy.caddy.host:
|
||||||
|
print(f" host: {proxy.caddy.host}")
|
||||||
|
print("\nNext: castle deploy " + name + " && castle service enable " + name)
|
||||||
|
return 0
|
||||||
@@ -53,6 +53,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
clone_parser = subparsers.add_parser("clone", help="Clone source for programs with repo:")
|
clone_parser = subparsers.add_parser("clone", help="Clone source for programs with repo:")
|
||||||
clone_parser.add_argument("name", nargs="?", help="Program to clone (default: all with repo:)")
|
clone_parser.add_argument("name", nargs="?", help="Program to clone (default: all with repo:)")
|
||||||
|
|
||||||
|
# castle expose — turn an existing program into a service
|
||||||
|
expose_parser = subparsers.add_parser("expose", help="Run an existing program as a service")
|
||||||
|
expose_parser.add_argument("name", help="Program to expose")
|
||||||
|
expose_parser.add_argument("--port", type=int, help="HTTP port the program binds")
|
||||||
|
expose_parser.add_argument("--health", default="/health", help="Health path (default: /health)")
|
||||||
|
expose_parser.add_argument("--path", help="Gateway proxy prefix (default: /<name>)")
|
||||||
|
expose_parser.add_argument(
|
||||||
|
"--host", help="Route by hostname instead of a path prefix (e.g. lakehouse.civil.lan)"
|
||||||
|
)
|
||||||
|
expose_parser.add_argument("--run", help="Console script / command to run (default: <name>)")
|
||||||
|
expose_parser.add_argument(
|
||||||
|
"--port-env", help="Env var the program reads for its port (e.g. LAKEHOUSED_DAEMON_PORT)"
|
||||||
|
)
|
||||||
|
expose_parser.add_argument(
|
||||||
|
"--no-proxy", action="store_true", help="Don't add a gateway route"
|
||||||
|
)
|
||||||
|
|
||||||
# castle delete — remove a program/service/job from the registry
|
# castle delete — remove a program/service/job from the registry
|
||||||
delete_parser = subparsers.add_parser("delete", help="Remove a program/service/job")
|
delete_parser = subparsers.add_parser("delete", help="Remove a program/service/job")
|
||||||
delete_parser.add_argument("name", help="Program, service, or job name")
|
delete_parser.add_argument("name", help="Program, service, or job name")
|
||||||
@@ -199,6 +216,11 @@ def main() -> int:
|
|||||||
|
|
||||||
return run_clone(args)
|
return run_clone(args)
|
||||||
|
|
||||||
|
elif args.command == "expose":
|
||||||
|
from castle_cli.commands.expose import run_expose
|
||||||
|
|
||||||
|
return run_expose(args)
|
||||||
|
|
||||||
elif args.command == "delete":
|
elif args.command == "delete":
|
||||||
from castle_cli.commands.delete import run_delete
|
from castle_cli.commands.delete import run_delete
|
||||||
|
|
||||||
|
|||||||
@@ -121,10 +121,37 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
|||||||
# Reload systemd daemon
|
# Reload systemd daemon
|
||||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
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
|
result.registry = registry
|
||||||
return result
|
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
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -172,6 +199,10 @@ def _build_deployed_service(
|
|||||||
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)
|
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)
|
# Merge defaults.env (overrides conventions)
|
||||||
@@ -187,10 +218,18 @@ def _build_deployed_service(
|
|||||||
# Build run_cmd
|
# Build run_cmd
|
||||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
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_path = None
|
||||||
|
proxy_host = None
|
||||||
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
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
|
# Resolve stack from referenced program
|
||||||
stack = None
|
stack = None
|
||||||
@@ -210,6 +249,7 @@ def _build_deployed_service(
|
|||||||
port=port,
|
port=port,
|
||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
proxy_path=proxy_path,
|
proxy_path=proxy_path,
|
||||||
|
proxy_host=proxy_host,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
managed=managed,
|
managed=managed,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,11 +20,35 @@ def generate_caddyfile_from_registry(
|
|||||||
If remote_registries is provided, cross-node routes are added for
|
If remote_registries is provided, cross-node routes are added for
|
||||||
remote services whose proxy_path doesn't conflict with local ones.
|
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
|
# Track local proxy paths for precedence
|
||||||
local_paths: set[str] = set()
|
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():
|
for name, deployed in registry.deployed.items():
|
||||||
if not deployed.proxy_path:
|
if not deployed.proxy_path:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ async def activate(name: str, config: CastleConfig, root: Path) -> ActionResult:
|
|||||||
|
|
||||||
# Process-backed: daemon, self-serving frontend, or job.
|
# Process-backed: daemon, self-serving frontend, or job.
|
||||||
if name in config.services or name in config.jobs:
|
if name in config.services or name in config.jobs:
|
||||||
# Ensure the program's binary is on PATH first (python programs).
|
# Ensure the program's binary is on PATH first (python programs). Skip the
|
||||||
if comp is not None and (comp.stack or comp.commands):
|
# 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)
|
res = await run_action("install", name, comp, root)
|
||||||
if res.status != "ok":
|
if res.status != "ok":
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ 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):
|
||||||
@@ -132,6 +138,10 @@ class ExposeSpec(BaseModel):
|
|||||||
class CaddySpec(BaseModel):
|
class CaddySpec(BaseModel):
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
path_prefix: str | None = None
|
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)
|
extra_snippets: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class Deployment:
|
|||||||
port: int | None = None
|
port: int | None = None
|
||||||
health_path: str | None = None
|
health_path: str | None = None
|
||||||
proxy_path: str | None = None
|
proxy_path: str | None = None
|
||||||
|
proxy_host: str | None = None
|
||||||
base_url: str | None = None
|
base_url: str | None = None
|
||||||
schedule: str | None = None
|
schedule: str | None = None
|
||||||
managed: bool = False
|
managed: bool = False
|
||||||
@@ -102,6 +103,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
port=comp_data.get("port"),
|
port=comp_data.get("port"),
|
||||||
health_path=comp_data.get("health_path"),
|
health_path=comp_data.get("health_path"),
|
||||||
proxy_path=comp_data.get("proxy_path"),
|
proxy_path=comp_data.get("proxy_path"),
|
||||||
|
proxy_host=comp_data.get("proxy_host"),
|
||||||
base_url=comp_data.get("base_url"),
|
base_url=comp_data.get("base_url"),
|
||||||
schedule=comp_data.get("schedule"),
|
schedule=comp_data.get("schedule"),
|
||||||
managed=comp_data.get("managed", False),
|
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
|
entry["health_path"] = comp.health_path
|
||||||
if comp.proxy_path:
|
if comp.proxy_path:
|
||||||
entry["proxy_path"] = comp.proxy_path
|
entry["proxy_path"] = comp.proxy_path
|
||||||
|
if comp.proxy_host:
|
||||||
|
entry["proxy_host"] = comp.proxy_host
|
||||||
if comp.base_url:
|
if comp.base_url:
|
||||||
entry["base_url"] = comp.base_url
|
entry["base_url"] = comp.base_url
|
||||||
if comp.schedule:
|
if comp.schedule:
|
||||||
|
|||||||
@@ -45,12 +45,15 @@ def _build_env() -> dict[str, str]:
|
|||||||
return env
|
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 a subprocess and return (returncode, combined output)."""
|
||||||
|
run_env = _build_env()
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=_build_env(),
|
env=run_env,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
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()
|
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:
|
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
|
||||||
"""Resolve source directory, raising ValueError if absent."""
|
"""Resolve source directory, raising ValueError if absent."""
|
||||||
if not comp.source:
|
if not comp.source:
|
||||||
@@ -181,7 +194,9 @@ class ReactViteHandler(StackHandler):
|
|||||||
|
|
||||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
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(
|
return ActionResult(
|
||||||
program=name, action="build", status="ok" if rc == 0 else "error", output=output
|
program=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,32 @@ class TestCaddyfileFromRegistry:
|
|||||||
assert "handle_path /test-svc/*" in caddyfile
|
assert "handle_path /test-svc/*" in caddyfile
|
||||||
assert "reverse_proxy localhost:19000" 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:
|
def test_skips_non_proxied(self) -> None:
|
||||||
"""Components without proxy_path are not in Caddyfile."""
|
"""Components without proxy_path are not in Caddyfile."""
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
|
|||||||
87
docs/findings-lakehouse.md
Normal file
87
docs/findings-lakehouse.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 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 add` auto-detected `behavior: daemon` + `stack: python-fastapi` from
|
||||||
|
the pyproject (fastapi/uvicorn in deps).
|
||||||
|
- Adding the service via the config API (`PUT /config/services/lakehouse`)
|
||||||
|
validated and persisted correctly.
|
||||||
|
- `castle deploy lakehouse` produced a correct systemd unit, registry entry,
|
||||||
|
and Caddy route, and auto-installed the editable package.
|
||||||
|
- `castle service enable lakehouse` started 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`.
|
||||||
|
|
||||||
|
**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.)
|
||||||
|
|
||||||
|
### #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-vite` stack, served by
|
||||||
|
`file_server`). Castle runs `pnpm build` *and* knows the serve prefix, so it
|
||||||
|
can pass `--base=/<prefix>/` automatically. Today `power-graph-app` only works
|
||||||
|
because its base was hand-set to match.
|
||||||
|
**Fix:** derive the build `--base` from 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`) so `base: "/"` stays
|
||||||
|
valid — also a generally useful castle capability.
|
||||||
|
**Fix:** support proxy-by-hostname in the Caddyfile generator + manifest.
|
||||||
|
|
||||||
|
### Nits
|
||||||
|
|
||||||
|
- **Redundant editable reinstall:** `castle deploy` installs the package, then
|
||||||
|
`castle install`/activate may `uv tool install` it again.
|
||||||
|
- **Entry-point discovery:** the service's `run.program` had to be `lakehoused`,
|
||||||
|
not `lakehouse` — the package ships two console scripts and castle gives no
|
||||||
|
hint which is the server. Surfacing a package's `[project.scripts]` would help.
|
||||||
@@ -56,6 +56,10 @@ import { defineConfig } from "vite"
|
|||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Castle sets VITE_BASE to the gateway serve prefix at build time (`/<name>/`,
|
||||||
|
// or `/` for the root app). Reading it here makes the bundle's absolute asset
|
||||||
|
// URLs resolve when served behind the gateway at a subpath — no hand-tuning.
|
||||||
|
base: process.env.VITE_BASE ?? "/",
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -65,6 +69,12 @@ export default defineConfig({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Serving behind the gateway.** A static frontend mounts at `/<name>/` (the
|
||||||
|
> root `castle-app` at `/`). Castle's `react-vite` build passes that prefix as
|
||||||
|
> `VITE_BASE`, so a frontend that reads it (above) works at its subpath with no
|
||||||
|
> manual `base`. Frontends that don't read `VITE_BASE` must hand-set `base` to
|
||||||
|
> match, or they'll request assets from the wrong path.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user