"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/). This module contains the core deploy logic shared by the CLI and API. It reads castle.yaml, resolves services/jobs into Deployments, writes the registry, generates systemd units and the Caddyfile, and copies frontend build outputs. """ from __future__ import annotations import os import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path from castle_core.config import ( DATA_DIR, SECRETS_DIR, SPECS_DIR, CastleConfig, ensure_dirs, load_config, resolve_env_split, ) from castle_core.generators.caddyfile import ( _DNS_TOKEN_ENV, generate_caddyfile_from_registry, service_proxy_targets, ) from castle_core.generators.tunnel import ( generate_tunnel_config, public_hostnames, ) from castle_core.generators.systemd import ( SECRET_ENV_DIR, generate_timer, generate_unit_from_deployed, secret_env_path, timer_name, unit_env_file, unit_name, ) from castle_core.manifest import JobSpec, ServiceSpec, behavior_for_runner, manager_for from castle_core.registry import ( REGISTRY_PATH, Deployment, NodeConfig, NodeRegistry, load_registry, save_registry, ) SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" @dataclass class DeployResult: """Result of a deploy operation.""" deployed_count: int = 0 messages: list[str] = field(default_factory=list) registry: NodeRegistry | None = None def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult: """Deploy from castle.yaml to ~/.castle/. Args: target_name: Deploy a single service/job by name, or None for all. root: Config root path. If None, uses find_castle_root(). Returns: DeployResult with deployed count, messages, and the registry. """ config = load_config(root) result = DeployResult() ensure_dirs() # Build node config node = NodeConfig( castle_root=str(config.root), gateway_port=config.gateway.port, gateway_tls=config.gateway.tls, gateway_domain=config.gateway.domain, acme_email=config.gateway.acme_email, acme_dns_provider=config.gateway.acme_dns_provider, public_domain=config.gateway.public_domain, tunnel_id=config.gateway.tunnel_id, ) # 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) # Deploy services for name, svc in config.services.items(): if target_name and name != target_name: continue deployed = _build_deployed_service(config, name, svc, result.messages) registry.deployed[name] = deployed result.deployed_count += 1 result.messages.append(_format_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, result.messages) registry.deployed[name] = deployed result.deployed_count += 1 result.messages.append(_format_deployed(name, deployed)) # Static frontends are served in place from their repo build output # (the Caddyfile roots directly at /) — no copy step. # Save registry save_registry(registry) result.messages.append(f"Registry written: {REGISTRY_PATH}") # Generate systemd units from registry _generate_systemd_units(config, registry) result.messages.append(f"Systemd units written: {SYSTEMD_USER_DIR}") # Converge: prune orphan units (full deploy only — partial deploys preserve siblings) if target_name is None: _prune_orphans(registry, result.messages) # Generate Caddyfile from registry caddyfile_path = SPECS_DIR / "Caddyfile" caddyfile_content = generate_caddyfile_from_registry(registry) caddyfile_path.write_text(caddyfile_content) result.messages.append(f"Caddyfile written: {caddyfile_path}") # Generate the cloudflared tunnel ingress from the registry's public services. _write_tunnel_config(registry, result.messages) # acme mode needs a domain + a DNS-provider token; warn (don't fail) if the # prerequisites the operator must set up by hand are missing. if (config.gateway.tls or "").lower() == "acme": _acme_preflight(config, result.messages) # Reload systemd daemon subprocess.run(["systemctl", "--user", "daemon-reload"], check=False) # Reload the gateway so the freshly written Caddyfile takes effect. Without # this, new/changed proxy routes sit on disk but the running Caddy keeps the # old config (a deployed service's route is silently dead until reload). _reload_gateway(result.messages) result.registry = registry return result # Gateway service name in the registry → its systemd unit (castle-castle-gateway). _GATEWAY_NAME = "castle-gateway" def _acme_preflight(config: CastleConfig, messages: list[str]) -> None: """Warn (never fail, never write) if acme-mode prerequisites are missing. acme mode needs a `gateway.domain`, the DNS-provider token mapped into the castle-gateway service env, and the matching secret on disk — all operator steps (castle never rewrites the user-authored gateway service YAML).""" gw = config.gateway if not gw.domain: messages.append( "Warning: gateway.tls=acme but gateway.domain is unset — host routes " "won't get a wildcard cert (serving plain HTTP on the gateway port)." ) return token_env = _DNS_TOKEN_ENV.get(gw.acme_dns_provider or "cloudflare", "CLOUDFLARE_API_TOKEN") svc = config.services.get(_GATEWAY_NAME) env = dict(svc.defaults.env) if (svc and svc.defaults and svc.defaults.env) else {} if token_env not in env: messages.append( f"Warning: acme mode needs {token_env} in the {_GATEWAY_NAME} service env. " f"Add to services/{_GATEWAY_NAME}.yaml → defaults.env: " f"{token_env}: ${{secret:{token_env}}}" ) if not (SECRETS_DIR / token_env).exists(): messages.append( f"Warning: secret '{token_env}' not found in {SECRETS_DIR} — place the " f"DNS-provider API token there (Cloudflare token scope: Zone:DNS:Edit)." ) _TUNNEL_NAME = "tunnel" # the cloudflared service is castle-tunnel def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None: """Write the cloudflared ingress config from the registry's public services. No public services (or no tunnel configured) → remove any stale config and leave the tunnel down. Otherwise write it, list the hostnames that still need a DNS route, and restart the tunnel service if it's running so it takes effect. """ config_path = SPECS_DIR / "cloudflared.yml" content = generate_tunnel_config(registry) if content is None: if config_path.exists(): config_path.unlink() messages.append("No public services — removed cloudflared config.") return config_path.write_text(content) hosts = public_hostnames(registry) messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)") # DNS is not automatic: each public host needs a CNAME → the tunnel. Surface the # exact commands rather than silently assuming they're routed. tid = registry.node.tunnel_id for h in hosts: messages.append(f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})") tunnel_unit = unit_name(_TUNNEL_NAME) active = subprocess.run( ["systemctl", "--user", "is-active", tunnel_unit], capture_output=True, text=True ) if active.stdout.strip() == "active": subprocess.run(["systemctl", "--user", "restart", tunnel_unit], check=False) messages.append("Tunnel reloaded.") else: messages.append( "Tunnel not running — enable it with 'castle service enable tunnel'." ) def _reload_gateway(messages: list[str]) -> None: """Reload Caddy if the gateway is running, so new routes take effect.""" gw_unit = unit_name(_GATEWAY_NAME) active = subprocess.run( ["systemctl", "--user", "is-active", gw_unit], capture_output=True, text=True, ) if active.stdout.strip() != "active": messages.append( "Gateway not running — skipped reload (start it with 'castle gateway start')." ) return result = subprocess.run( ["systemctl", "--user", "reload", gw_unit], capture_output=True, text=True ) if result.returncode == 0: messages.append("Gateway reloaded.") else: messages.append(f"Warning: gateway reload failed: {result.stderr.strip()}") # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _public_url( config: CastleConfig, name: str, exposed: bool, port: int | None ) -> str | None: """The service's publicly-reachable base URL — the ``${public_url}`` placeholder. When the service is exposed through the gateway under acme TLS, this is its trusted subdomain ``https://.`` — the origin an app must allowlist for CORS/WebSocket/secure-context to work behind the gateway. It tracks ``gateway.domain`` automatically, so a domain change needs no app edit. Off mode / port-only falls back to the node-local ``http://localhost:``; ``None`` when there's no port to reach it on (nothing to interpolate). """ gw = config.gateway if exposed and str(gw.tls or "").lower() == "acme" and gw.domain: return f"https://{name}.{gw.domain}" if port is not None: return f"http://localhost:{port}" return None def _env_context( name: str, config_key: str, port: int | None, public_url: str | None = None ) -> dict[str, str]: """Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}.""" ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)} if port is not None: ctx["port"] = str(port) if public_url is not None: ctx["public_url"] = public_url return ctx def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None: """Write a deployment's resolved secrets to a mode-0600 env file. Keeps secrets out of the generated unit file and the process argv: systemd loads it via ``EnvironmentFile=`` and docker via ``--env-file``. Returns the path, or ``None`` (after removing any stale file) when there are no secrets, so a service that drops its last secret doesn't leave a dangling file. """ path = secret_env_path(name) if not secret_env: path.unlink(missing_ok=True) return None SECRET_ENV_DIR.mkdir(parents=True, exist_ok=True) os.chmod(SECRET_ENV_DIR, 0o700) # O_TRUNC keeps a pre-existing file's mode, so chmod explicitly afterwards. fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: for key, value in secret_env.items(): f.write(f"{key}={value}\n") os.chmod(path, 0o600) return path 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.program and spec.program in config.programs: return config.programs[spec.program].description return None def _build_deployed_service( config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str] ) -> Deployment: """Build a Deployment from a ServiceSpec.""" run = svc.run # The data-dir placeholder is keyed by the program the service runs, not the # service name (e.g. job `protonmail-sync` runs program `protonmail` → # /data/castle/protonmail). Falls back to the service name. config_key = svc.program or name # Only systemd-managed runners get a unit. caddy (static), path (tools), and # none (remote) have no local process — the manager mapping is the single # source of truth, so there's no runner special-casing here. managed = manager_for(run.runner) == "systemd" if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: managed = False # Routing comes from the shared deriver, so the registry written here and the # Caddyfile computed from castle.yaml stay in lockstep. `expose` is the # checkbox; the subdomain is the service name. A `static` service is inherently # served (that's its whole purpose), so it's always exposed. expose, port, base_url = service_proxy_targets(name, svc) if run.runner == "static": expose = True health_path = None if svc.expose and svc.expose.http: health_path = svc.expose.http.health_path # Env is exactly what's declared in defaults.env — no hidden convention # injection. ${port}/${data_dir}/${name}/${public_url} let the program's own # env var names map to castle's computed values without hardcoding them. # Secret-bearing vars # are split out so they never land in the unit file or process argv — they're # written to a mode-0600 env file referenced via EnvironmentFile=/--env-file. raw_env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {} public_url = _public_url(config, name, expose, port) env, secret_env = resolve_env_split( raw_env, _env_context(name, config_key, port, public_url) ) secret_env_file = _write_secret_env_file(name, secret_env) # `command`-runner services resolve a tool on PATH → ensure it's installed. # `python`-runner services run in place via `uv run` (below) — no tool venv. if run.runner == "command": _ensure_python_tool(config, svc.program, messages) # Build run_cmd (container runners get --env-file for the secrets). source_dir = _program_source_dir(config, svc.program) run_cmd = _build_run_cmd( name, run, env, messages, source_dir, secret_env_file=secret_env_file, ) stop_cmd = _build_stop_cmd(name, run, source_dir) # A static service serves / via the gateway. static_root = None if run.runner == "static" and source_dir is not None: static_root = str(source_dir / run.root) # type: ignore[union-attr] # Resolve stack from referenced program stack = None if svc.program and svc.program in config.programs: stack = config.programs[svc.program].stack return Deployment( runner=run.runner, run_cmd=run_cmd, stop_cmd=stop_cmd, env=env, secret_env_keys=sorted(secret_env), description=_resolve_description(config, svc), behavior=behavior_for_runner(run.runner), stack=stack, port=port, health_path=health_path, subdomain=(name if expose else None), public=bool(getattr(svc, "public", False) and expose), static_root=static_root, base_url=base_url, managed=managed, ) def _build_deployed_job( config: CastleConfig, name: str, job: JobSpec, messages: list[str] ) -> Deployment: """Build a Deployment from a JobSpec.""" run = job.run # ${data_dir} is keyed by the program the job runs, not the job name — see # _build_deployed_service. Falls back to the job name. config_key = job.program or name raw_env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {} env, secret_env = resolve_env_split(raw_env, _env_context(name, config_key, None)) secret_env_file = _write_secret_env_file(name, secret_env) if run.runner == "command": _ensure_python_tool(config, job.program, messages) run_cmd = _build_run_cmd( name, run, env, messages, _program_source_dir(config, job.program), secret_env_file=secret_env_file, ) stack = None if job.program and job.program in config.programs: stack = config.programs[job.program].stack return Deployment( runner=run.runner, run_cmd=run_cmd, env=env, secret_env_keys=sorted(secret_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.""" 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 _program_source_dir(config: CastleConfig, program: str | None) -> Path | None: """The absolute source dir of a referenced program, if any. `load_config` has already resolved `source` to an absolute path (repo: and relative forms included), so this is a plain lookup.""" if program and program in config.programs: src = config.programs[program].source if src: return Path(src) return None def _ensure_python_tool( config: CastleConfig, program: str | None, messages: list[str] ) -> None: """Ensure a Python program's editable install is current. Only the `command` runner needs this — it resolves a tool on PATH. The `python` runner runs in place via `uv run` and never touches a tool venv.""" if not program or program not in config.programs: return comp = config.programs[program] 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(): messages.append(f"Warning: source not found: {source_dir}") return if not _python_tool_needs_install(program): return pkg_spec = str(source_dir) if comp.install_extras: pkg_spec += "[" + ",".join(comp.install_extras) + "]" messages.append(f"Installing {program} from {source_dir}...") result = subprocess.run( ["uv", "tool", "install", "--editable", pkg_spec, "--force"], capture_output=True, text=True, ) if result.returncode != 0: messages.append( f"Error: {program} install failed:\n{result.stdout}{result.stderr}" ) else: messages.append(f"Installed {program}") def _build_run_cmd( name: str, run: object, env: dict[str, str], messages: list[str], source_dir: Path | None = None, secret_env_file: Path | None = None, ) -> list[str]: """Build a run command list from a RunSpec. ``env`` holds plain (non-secret) vars only; ``secret_env_file`` is the mode-0600 file holding the deployment's secrets. For container runners the secrets are passed via ``--env-file`` (keeping them out of the argv); systemd-launched runners get them via ``EnvironmentFile=`` on the unit, so ``secret_env_file`` is unused here for those. """ match run.runner: # type: ignore[union-attr] case "python": # Run the program in place from its own project venv via `uv run`, # which syncs the env to the lockfile before launching. One venv per # program (no separate tool venv); restart picks up both code and # dependency changes. Falls back to a PATH lookup only when there's no # resolvable source (a service that declares run.program without a # catalog program). if source_dir and source_dir.is_dir(): uv = shutil.which("uv") or "uv" cmd = [uv, "run", "--project", str(source_dir), "--no-dev", run.program] # type: ignore[union-attr] else: resolved = shutil.which(run.program) # type: ignore[union-attr] if not resolved: messages.append( f"Warning: '{run.program}' has no source dir and is not on " # type: ignore[union-attr] f"PATH. Declare a program source, or install it." ) cmd = [resolved or run.program] # type: ignore[union-attr] if run.args: # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr] return cmd case "command": cmd = list(run.argv) # type: ignore[union-attr] resolved = shutil.which(cmd[0]) if resolved: cmd[0] = resolved return cmd case "container": runtime = shutil.which("docker") or shutil.which("podman") or "docker" # Container name derives from the SERVICE name (matches the systemd unit), # not the image name — so `castle-` is stable and collision-free. cmd = [runtime, "run", "--rm", f"--name=castle-{name}"] for container_port, host_port in run.ports.items(): # type: ignore[union-attr] cmd.extend(["-p", f"{host_port}:{container_port}"]) for vol in run.volumes: # type: ignore[union-attr] cmd.extend(["-v", vol]) for key, val in run.env.items(): # type: ignore[union-attr] cmd.extend(["-e", f"{key}={val}"]) # env is plain-only; secrets go via --env-file so they never hit argv. for key, val in env.items(): cmd.extend(["-e", f"{key}={val}"]) if secret_env_file is not None: cmd.extend(["--env-file", str(secret_env_file)]) if run.workdir: # type: ignore[union-attr] cmd.extend(["-w", run.workdir]) # type: ignore[union-attr] cmd.append(run.image) # type: ignore[union-attr] if run.command: # type: ignore[union-attr] cmd.extend(run.command) # type: ignore[union-attr] if run.args: # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr] return cmd case "compose": # A whole docker-compose stack supervised as one unit. `up` runs # attached (no -d) so systemd Type=simple owns the lifecycle; teardown # is a generated ExecStop (`down`, see _build_stop_cmd). Secrets/env # reach compose via the unit's Environment=/EnvironmentFile= (compose # interpolates from the process env), not argv — so nothing here. return [*_compose_base(name, run, source_dir), "up"] case "node": # Like the python runner bakes `--project ` into `uv run`, the # node runner bakes `--dir ` so the package manager runs the # script in the program's source dir — the systemd unit carries no # WorkingDirectory, so a bare `pnpm run` would otherwise execute in the # service's (wrong) cwd. Resolve the package manager to an absolute path. pm = shutil.which(run.package_manager) or run.package_manager # type: ignore[union-attr] cmd = [pm] if source_dir and source_dir.is_dir(): cmd.extend(["--dir", str(source_dir)]) cmd.extend(["run", run.script]) # type: ignore[union-attr] if run.args: # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr] return cmd case "remote": return [] case "static": # No process — the gateway file_servers this service's root. return [] case "path": # No process — installed on PATH via `uv tool install` at enable time. return [] case _: raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr] def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]: """The shared ``docker compose -p -f `` prefix for a stack. The compose file is resolved against the program source (like the node runner uses ``--dir``) so the unit — which carries no WorkingDirectory — finds it. The project name defaults to the unit name so a stack's containers/networks are namespaced and collision-free. """ runtime = shutil.which("docker") or shutil.which("podman") or "docker" project = run.project_name or f"castle-{name}" # type: ignore[union-attr] compose_file = Path(run.file) # type: ignore[union-attr] if not compose_file.is_absolute() and source_dir is not None: compose_file = source_dir / compose_file return [runtime, "compose", "-p", project, "-f", str(compose_file)] def _build_stop_cmd(name: str, run: object, source_dir: Path | None) -> list[str]: """The ExecStop teardown command for a runner, or [] if a plain SIGTERM suffices. Compose stacks need an explicit ``down`` so networks/anonymous volumes are reclaimed rather than left dangling when the unit stops. """ if run.runner == "compose": # type: ignore[union-attr] return [*_compose_base(name, run, source_dir), "down"] return [] def _format_deployed(name: str, deployed: Deployment) -> str: """Format deployment summary for a component.""" parts = [name] if deployed.port: parts.append(f"port={deployed.port}") if deployed.schedule: parts.append(f"schedule={deployed.schedule}") if deployed.subdomain: parts.append(f"subdomain={deployed.subdomain}") return " ".join(parts) def _desired_unit_files(registry: NodeRegistry) -> set[str]: """Exact set of unit filenames that should exist on disk for this registry.""" files: set[str] = set() for name, deployed in registry.deployed.items(): if not deployed.managed: continue files.add(unit_name(name)) if deployed.schedule: files.add(timer_name(name)) return files def _teardown_unit(unit_file: str, messages: list[str]) -> None: """Stop, disable, and unlink a systemd unit file. Caller batches daemon-reload.""" path = SYSTEMD_USER_DIR / unit_file if not path.exists(): return subprocess.run(["systemctl", "--user", "stop", unit_file], check=False) subprocess.run(["systemctl", "--user", "disable", unit_file], check=False) path.unlink() messages.append(f"Pruned orphan unit: {unit_file}") # Remove the matching secret env file (only services have one; not timers). if unit_file.endswith(".service"): (SECRET_ENV_DIR / f"{unit_file}.env").unlink(missing_ok=True) def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None: """Remove castle-* units no longer backed by a managed registry entry. The `castle-` prefix is the ownership namespace: any castle-*.service/.timer on disk that isn't in the desired set is an orphan (a removed/unmanaged/unscheduled component) and is torn down. Only call on a FULL deploy — the desired set must reflect the whole registry, not a single --target. """ desired = _desired_unit_files(registry) if not SYSTEMD_USER_DIR.is_dir(): return for pattern in ("castle-*.service", "castle-*.timer"): for path in sorted(SYSTEMD_USER_DIR.glob(pattern)): if path.name not in desired: _teardown_unit(path.name, messages) 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 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 svc_name = unit_name(name) svc_content = generate_unit_from_deployed( name, deployed, systemd_spec, env_file=unit_env_file(deployed, name) ) (SYSTEMD_USER_DIR / svc_name).write_text(svc_content) 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)