refactor: Extract deploy logic to castle-core and add deploy API endpoint

- Extract all deploy logic from CLI into castle-core/deploy.py as a reusable deploy() function
  * Resolves services/jobs to DeployedComponents
  * Generates systemd units and Caddyfile
  * Copies frontend assets and reloads systemd
  * Returns DeployResult with deployed count and messages
- Simplify CLI deploy command to a thin wrapper calling castle_core.deploy()
- Add POST /deploy endpoint to castle-api for remote deployment management
  * Supports optional {name} body param to deploy one or all services
  * Returns deployment result as JSON
- Register deploy router in castle-api main.py
- Update README with new deploy endpoint documentation

This enables full remote management: build, test, lint, and deploy any castle
node over HTTP (e.g. POST http://node:9000/api/deploy)
This commit is contained in:
2026-04-27 22:01:39 -07:00
parent e1a9386177
commit 691216b04e
7 changed files with 539 additions and 387 deletions

View File

@@ -1,400 +1,21 @@
"""castle deploy — bridge spec (castle.yaml) to runtime (~/.castle/)."""
"""castle deploy — thin CLI wrapper around castle_core.deploy."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_core.config import (
CONTENT_DIR,
DATA_DIR,
SPECS_DIR,
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
timer_name,
unit_name,
)
from castle_core.manifest import JobSpec, ServiceSpec
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
from castle_core.deploy import deploy
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy from castle.yaml to ~/.castle/."""
config = load_config()
target_name = getattr(args, "name", None)
result = deploy(target_name=target_name)
ensure_dirs()
for msg in result.messages:
print(f" {msg}")
# Build node config
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
# Load existing registry to preserve entries not being redeployed,
# or start fresh if deploying all
if target_name and REGISTRY_PATH.exists():
try:
existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
except (FileNotFoundError, ValueError):
registry = NodeRegistry(node=node)
else:
registry = NodeRegistry(node=node)
deployed_count = 0
# Deploy services
for name, svc in config.services.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_service(config, name, svc)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
# Deploy jobs
for name, job in config.jobs.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_job(config, name, job)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
# Handle castle-app build artifacts
_copy_app_static(config)
# Save registry
save_registry(registry)
print(f"\nRegistry written: {REGISTRY_PATH}")
# Generate systemd units from registry
_generate_systemd_units(config, registry)
# Generate Caddyfile from registry
caddyfile_path = SPECS_DIR / "Caddyfile"
caddyfile_content = generate_caddyfile_from_registry(registry)
caddyfile_path.write_text(caddyfile_content)
print(f"Caddyfile written: {caddyfile_path}")
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
print(f"\nDeployed {deployed_count} item(s).")
print("Run 'castle services start' to start all services.")
print(f"\nDeployed {result.deployed_count} item(s).")
if result.deployed_count > 0:
print("Run 'castle services start' to start all services.")
return 0
def _env_prefix(name: str) -> str:
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> str | None:
"""Get description, falling through to program if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.programs:
return config.programs[spec.component].description
return None
def _build_deployed_service(config: CastleConfig, name: str, svc: ServiceSpec) -> DeployedComponent:
"""Build a DeployedComponent from a ServiceSpec."""
run = svc.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention (for managed services)
managed = run.runner != "remote" # Remote services have no local process
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
if managed:
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / name)
# Port convention (if exposed)
port = None
health_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
env[f"{prefix}_PORT"] = str(port)
health_path = svc.expose.http.health_path
# Merge defaults.env (overrides conventions)
if svc.defaults and svc.defaults.env:
env.update(svc.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.component)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Proxy path
proxy_path = None
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
# Resolve stack from referenced program
stack = None
if svc.component and svc.component in config.programs:
stack = config.programs[svc.component].stack
# Remote services proxy to an external base_url
base_url = getattr(run, "base_url", None)
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, svc),
behavior="daemon",
stack=stack,
port=port,
health_path=health_path,
proxy_path=proxy_path,
base_url=base_url,
managed=managed,
)
def _build_deployed_job(config: CastleConfig, name: str, job: JobSpec) -> DeployedComponent:
"""Build a DeployedComponent from a JobSpec."""
run = job.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention
env[f"{prefix}_DATA_DIR"] = str(DATA_DIR / name)
# Merge defaults.env (overrides conventions)
if job.defaults and job.defaults.env:
env.update(job.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, job.component)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Resolve stack from referenced program
stack = None
if job.component and job.component in config.programs:
stack = config.programs[job.component].stack
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, job),
behavior="tool",
stack=stack,
schedule=job.schedule,
managed=True,
)
def _python_tool_needs_install(program: str) -> bool:
"""Check if a Python tool's editable install is broken.
Returns True if the binary is missing or the editable install's
.pth file points to a directory that no longer exists.
"""
if not shutil.which(program):
return True
tool_dir = Path.home() / ".local" / "share" / "uv" / "tools" / program
if not tool_dir.exists():
return True
for pth_file in tool_dir.glob("lib/python*/site-packages/*.pth"):
if pth_file.name == "_virtualenv.pth":
continue
try:
target = pth_file.read_text().strip()
except OSError:
continue
if not target or target.startswith("import "):
continue
if not Path(target).exists():
return True
return False
def _ensure_python_tool(config: CastleConfig, component: str | None) -> None:
"""Ensure a Python program's editable install is current.
Checks if the uv tool's .pth file points to a directory that still
exists. If the binary is missing or the editable path is stale,
reinstalls from the source directory defined in the program spec.
"""
if not component or component not in config.programs:
return
comp = config.programs[component]
if not comp.source or not comp.stack or not comp.stack.startswith("python"):
return
source_dir = Path(comp.source)
if not source_dir.is_dir():
print(f" Warning: source not found: {source_dir}")
return
if not _python_tool_needs_install(component):
return
pkg_spec = str(source_dir)
if comp.install_extras:
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
print(f" Installing {component} from {source_dir}...")
result = subprocess.run(
["uv", "tool", "install", "--editable", pkg_spec, "--force"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f" Error: {component} install failed:\n{result.stdout}{result.stderr}")
else:
print(f" Installed {component}")
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
case "python":
resolved = shutil.which(run.program)
if not resolved:
print(
f" Warning: '{run.program}' not on PATH. "
f"Install with: uv tool install --editable <source>"
)
cmd = [resolved or run.program]
if run.args:
cmd.extend(run.args)
return cmd
case "command":
cmd = list(run.argv)
resolved = shutil.which(cmd[0])
if resolved:
cmd[0] = resolved
return cmd
case "container":
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
image_name = run.image.split("/")[-1].split(":")[0]
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
for container_port, host_port in run.ports.items():
cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
if run.args:
cmd.extend(run.args)
return cmd
case "node":
cmd = [run.package_manager, "run", run.script]
if run.args:
cmd.extend(run.args)
return cmd
case "remote":
return [] # No local process for remote services
case _:
raise ValueError(f"Unsupported runner: {run.runner}")
def _print_deployed(name: str, deployed: DeployedComponent) -> None:
"""Print deployment summary for a component."""
parts = [f" {name}"]
if deployed.port:
parts.append(f"port={deployed.port}")
if deployed.schedule:
parts.append(f"schedule={deployed.schedule}")
if deployed.proxy_path:
parts.append(f"proxy={deployed.proxy_path}")
print(" ".join(parts))
def _copy_app_static(config: CastleConfig) -> None:
"""Copy frontend build outputs to ~/.castle/static/<name>/."""
for name, comp in config.programs.items():
if comp.behavior != "frontend":
continue
if not (comp.build and comp.build.outputs):
continue
source_dir = comp.source_dir or name
for output in comp.build.outputs:
src = config.root / source_dir / output
if src.exists():
dest = CONTENT_DIR / name
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
print(f" Static: {src}{dest}")
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
# Get systemd spec from config (services or jobs)
systemd_spec = None
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
# Generate and write service unit
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer for jobs
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
print(f"Systemd units written: {SYSTEMD_USER_DIR}")