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:
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.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
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user