feat: Add support for remote services

- Added base_url field to DeployedComponent for external URL proxying
- Updated Caddyfile generator to proxy to base_url when present instead of requiring local port
- Added runner: remote handling in deploy command (returns empty run_cmd, marks as unmanaged)
- Reformatted long ternary expression in registry.py for readability

This enables castle to manage services that proxy to external endpoints without requiring a local process.
This commit is contained in:
2026-04-27 20:48:25 -07:00
parent a28d40c49e
commit a553525c71
3 changed files with 26 additions and 4 deletions

View File

@@ -122,7 +122,7 @@ def _build_deployed_service(config: CastleConfig, name: str, svc: ServiceSpec) -
env: dict[str, str] = {} env: dict[str, str] = {}
# Data dir convention (for managed services) # Data dir convention (for managed services)
managed = True # Services are always managed by default managed = run.runner != "remote" # Remote services have no local process
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False managed = False
if managed: if managed:
@@ -159,6 +159,9 @@ def _build_deployed_service(config: CastleConfig, name: str, svc: ServiceSpec) -
if svc.component and svc.component in config.programs: if svc.component and svc.component in config.programs:
stack = config.programs[svc.component].stack stack = config.programs[svc.component].stack
# Remote services proxy to an external base_url
base_url = getattr(run, "base_url", None)
return DeployedComponent( return DeployedComponent(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
@@ -169,6 +172,7 @@ def _build_deployed_service(config: CastleConfig, name: str, svc: ServiceSpec) -
port=port, port=port,
health_path=health_path, health_path=health_path,
proxy_path=proxy_path, proxy_path=proxy_path,
base_url=base_url,
managed=managed, managed=managed,
) )
@@ -322,6 +326,8 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
if run.args: if run.args:
cmd.extend(run.args) cmd.extend(run.args)
return cmd return cmd
case "remote":
return [] # No local process for remote services
case _: case _:
raise ValueError(f"Unsupported runner: {run.runner}") raise ValueError(f"Unsupported runner: {run.runner}")

View File

@@ -24,12 +24,16 @@ def generate_caddyfile_from_registry(
local_paths: set[str] = set() local_paths: set[str] = set()
for name, deployed in registry.deployed.items(): for name, deployed in registry.deployed.items():
if not deployed.proxy_path or not deployed.port: if not deployed.proxy_path:
continue
# Need either a local port or a remote base_url to proxy to
if not deployed.port and not deployed.base_url:
continue continue
local_paths.add(deployed.proxy_path) local_paths.add(deployed.proxy_path)
target = deployed.base_url or f"localhost:{deployed.port}"
lines.append(f" handle_path {deployed.proxy_path}/* {{") lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy localhost:{deployed.port}") lines.append(f" reverse_proxy {target}")
lines.append(" }") lines.append(" }")
lines.append("") lines.append("")

View File

@@ -40,6 +40,7 @@ class DeployedComponent:
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
base_url: str | None = None
schedule: str | None = None schedule: str | None = None
managed: bool = False managed: bool = False
@@ -82,7 +83,15 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
behavior = comp_data.get("behavior") behavior = comp_data.get("behavior")
if behavior is None: if behavior is None:
category = comp_data.get("category", "service") category = comp_data.get("category", "service")
behavior = "daemon" if category == "service" else "tool" if category in ("job", "tool") else "frontend" if category == "frontend" else category behavior = (
"daemon"
if category == "service"
else "tool"
if category in ("job", "tool")
else "frontend"
if category == "frontend"
else category
)
deployed[name] = DeployedComponent( deployed[name] = DeployedComponent(
runner=comp_data.get("runner", "command"), runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
@@ -93,6 +102,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"),
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),
) )
@@ -136,6 +146,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.base_url:
entry["base_url"] = comp.base_url
if comp.schedule: if comp.schedule:
entry["schedule"] = comp.schedule entry["schedule"] = comp.schedule
if comp.managed: if comp.managed: