"""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 ( 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" def run_deploy(args: argparse.Namespace) -> int: """Deploy from castle.yaml to ~/.castle/.""" config = load_config() target_name = getattr(args, "name", None) ensure_dirs() # 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.") 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 = 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_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 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, 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 " ) 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 _: 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//.""" 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}")