319 lines
9.9 KiB
Python
319 lines
9.9 KiB
Python
"""castle deploy — bridge spec (castle.yaml) to runtime (~/.castle/)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from castle_core.config import (
|
|
GENERATED_DIR,
|
|
STATIC_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,
|
|
)
|
|
|
|
DATA_ROOT = Path("/data/castle")
|
|
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
|
|
|
|
|
def run_deploy(args: argparse.Namespace) -> int:
|
|
"""Deploy components from castle.yaml to ~/.castle/."""
|
|
config = load_config()
|
|
target_name = getattr(args, "component", None)
|
|
|
|
ensure_dirs()
|
|
|
|
# Build node config
|
|
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
|
|
|
|
# Load existing registry to preserve components 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 = GENERATED_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} component(s).")
|
|
print("Run 'castle services start' to start all services.")
|
|
return 0
|
|
|
|
|
|
def _env_prefix(name: str) -> str:
|
|
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
|
|
return name.replace("-", "_").upper()
|
|
|
|
|
|
def _resolve_description(
|
|
config: CastleConfig, spec: ServiceSpec | JobSpec
|
|
) -> str | None:
|
|
"""Get description, falling through to component if referenced."""
|
|
if spec.description:
|
|
return spec.description
|
|
if spec.component and spec.component in config.components:
|
|
return config.components[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 = True # Services are always managed by default
|
|
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
|
managed = False
|
|
if managed:
|
|
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / 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)
|
|
|
|
# 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}"
|
|
|
|
return DeployedComponent(
|
|
runner=run.runner,
|
|
run_cmd=run_cmd,
|
|
env=env,
|
|
description=_resolve_description(config, svc),
|
|
category="service",
|
|
port=port,
|
|
health_path=health_path,
|
|
proxy_path=proxy_path,
|
|
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_ROOT / 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)
|
|
|
|
# Build run_cmd
|
|
run_cmd = _build_run_cmd(run, env)
|
|
|
|
return DeployedComponent(
|
|
runner=run.runner,
|
|
run_cmd=run_cmd,
|
|
env=env,
|
|
description=_resolve_description(config, job),
|
|
category="job",
|
|
schedule=job.schedule,
|
|
managed=True,
|
|
)
|
|
|
|
|
|
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.tool)
|
|
if not resolved:
|
|
print(
|
|
f" Warning: '{run.tool}' not on PATH. "
|
|
f"Install with: uv tool install --editable <source>"
|
|
)
|
|
cmd = [resolved or run.tool]
|
|
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("podman") or shutil.which("docker") or "podman"
|
|
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 _:
|
|
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 castle-app build output to ~/.castle/static/castle-app/."""
|
|
if "castle-app" not in config.components:
|
|
return
|
|
|
|
comp = config.components["castle-app"]
|
|
if not (comp.build and comp.build.outputs):
|
|
return
|
|
|
|
source_dir = comp.source_dir or "app"
|
|
for output in comp.build.outputs:
|
|
src = config.root / source_dir / output
|
|
if src.exists():
|
|
dest = STATIC_DIR / "castle-app"
|
|
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}")
|