diff --git a/app/vite.config.ts b/app/vite.config.ts index 67d38b6..5c8916e 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" 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()], resolve: { alias: { diff --git a/cli/src/castle_cli/commands/expose.py b/cli/src/castle_cli/commands/expose.py new file mode 100644 index 0000000..cdae0bb --- /dev/null +++ b/cli/src/castle_cli/commands/expose.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index bae8130..d898d93 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -53,6 +53,23 @@ def build_parser() -> argparse.ArgumentParser: 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:)") + # 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: /)") + 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: )") + 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 delete_parser = subparsers.add_parser("delete", help="Remove a program/service/job") delete_parser.add_argument("name", help="Program, service, or job name") @@ -199,6 +216,11 @@ def main() -> int: return run_clone(args) + elif args.command == "expose": + from castle_cli.commands.expose import run_expose + + return run_expose(args) + elif args.command == "delete": from castle_cli.commands.delete import run_delete diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index c46433c..d93ce99 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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 /. + 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, ) diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index b3df316..9a02d5e 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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 diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 6321a56..4231cef 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -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 diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index fce12d0..a3a1920 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 _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) diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 80eef5f..8476d3a 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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: diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index f6e9678..3ee7c12 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -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 '//'. 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 + # // (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 ) diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 0519ba7..7ab6d08 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -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( diff --git a/docs/findings-lakehouse.md b/docs/findings-lakehouse.md new file mode 100644 index 0000000..08f7dc9 --- /dev/null +++ b/docs/findings-lakehouse.md @@ -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 ` 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 [--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 +`_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=//` 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. diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index 0cda840..1a7a992 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -56,6 +56,10 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" export default defineConfig({ + // Castle sets VITE_BASE to the gateway serve prefix at build time (`//`, + // 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()], resolve: { alias: { @@ -65,6 +69,12 @@ export default defineConfig({ }) ``` +> **Serving behind the gateway.** A static frontend mounts at `//` (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 ```