"""Test fixtures for castle-api.""" import os as _os # Tests must not read the host's real secret backend (castle.yaml may point # at OpenBao); force the file backend unless CI explicitly overrides. _os.environ.setdefault("CASTLE_SECRET_BACKEND", "file") import socket import subprocess import time import urllib.request from collections.abc import Generator from pathlib import Path import pytest import yaml from fastapi.testclient import TestClient import castle_api.config as api_config from castle_api.main import app from castle_core.registry import ( Deployment, NodeConfig, NodeRegistry, save_registry, ) def _free_port() -> int: s = socket.socket() s.bind(("", 0)) port = s.getsockname()[1] s.close() return port def _docker_available() -> bool: try: return subprocess.run( ["docker", "info"], capture_output=True, timeout=5 ).returncode == 0 except Exception: return False @pytest.fixture def nats_url() -> Generator[str, None, None]: """A throwaway NATS+JetStream broker in docker (fresh per test for clean buckets). Skips if docker is unavailable.""" if not _docker_available(): pytest.skip("docker unavailable — skipping NATS integration tests") cport, mport = _free_port(), _free_port() name = f"castle-test-nats-{cport}" subprocess.run( ["docker", "run", "-d", "--rm", "--name", name, "-p", f"{cport}:4222", "-p", f"{mport}:8222", "nats:2", "-js", "-m", "8222"], check=True, capture_output=True, ) try: for _ in range(50): # wait for readiness try: urllib.request.urlopen(f"http://127.0.0.1:{mport}/healthz", timeout=1) break except Exception: time.sleep(0.2) yield f"nats://127.0.0.1:{cport}" finally: subprocess.run(["docker", "rm", "-f", name], capture_output=True) def _modernize_deployment(spec: dict) -> dict: """Translate a test's terse legacy deployment dict to the current manager-discriminated shape (production dropped this read-compat post-migration). ``proxy``/``public`` → ``reach``; ``run.runner`` → ``manager`` (+ ``run.launcher``).""" d = dict(spec) proxy = bool(d.pop("proxy", False)) public = bool(d.pop("public", False)) if "reach" not in d: if public: d["reach"] = "public" elif proxy: d["reach"] = "internal" if "manager" not in d: run = dict(d.pop("run", None) or {}) runner = run.get("runner") if runner == "static": d["manager"] = "caddy" if run.get("root"): d["root"] = run["root"] elif runner == "path": d["manager"] = "path" elif runner == "remote": d["manager"] = "none" for k in ("base_url", "health_url"): if run.get(k): d[k] = run[k] else: launch = {k: v for k, v in run.items() if k != "runner"} launch["launcher"] = runner d["manager"] = "systemd" d["run"] = launch return d def _store_for(spec: dict) -> str: if spec.get("schedule"): return "jobs" return {"systemd": "services", "caddy": "statics", "path": "tools", "none": "references"}[ spec["manager"] ] def _write_castle_config(root: Path, config: dict) -> None: """Scatter a nested castle config dict into the on-disk layout: castle.yaml globals, programs/.yaml, and deployments//.yaml (fields modernized).""" globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")} (root / "castle.yaml").write_text(yaml.dump(globals_data, default_flow_style=False)) programs = config.get("programs") or {} if programs: (root / "programs").mkdir(parents=True, exist_ok=True) for name, spec in programs.items(): (root / "programs" / f"{name}.yaml").write_text( yaml.dump(spec, default_flow_style=False) ) for section in ("services", "jobs", "deployments"): for name, spec in (config.get(section) or {}).items(): modern = _modernize_deployment(spec) store_dir = root / "deployments" / _store_for(modern) store_dir.mkdir(parents=True, exist_ok=True) (store_dir / f"{name}.yaml").write_text( yaml.dump(modern, default_flow_style=False) ) @pytest.fixture def castle_root(tmp_path: Path) -> Generator[Path, None, None]: """Create a temporary castle root with directory-per-resource config.""" config = { "gateway": {"port": 9000}, "programs": { "test-tool": { "description": "Test tool", "source": "test-tool", "system_dependencies": ["pandoc"], }, "test-tool-2": { "description": "Another test tool", "source": "test-tool-2", "version": "2.0.0", }, "wired-in": { "description": "Adopted repo, no stack", "source": "wired-in", "repo": "https://github.com/someone/wired-in.git", "commands": { "lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/wired-in"]], }, }, }, "services": { # Path deployments — behavior "tool" derives from the `path` runner # (behavior is derived from deployments, never stored). "test-tool": {"program": "test-tool", "run": {"runner": "path"}}, "test-tool-2": {"program": "test-tool-2", "run": {"runner": "path"}}, "wired-in": {"program": "wired-in", "run": {"runner": "path"}}, "test-svc": { "program": "test-svc-comp", "description": "Test service", "run": { "runner": "python", "program": "test-svc", }, "expose": { "http": { "internal": {"port": 19000}, "health_path": "/health", } }, "proxy": True, "manage": {"systemd": {}}, }, }, "jobs": { "test-job": { "description": "Test job", "run": { "runner": "command", "argv": ["test-job"], }, "schedule": "0 2 * * *", }, }, } _write_castle_config(tmp_path, config) yield tmp_path @pytest.fixture def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, None]: """Create a temporary registry.yaml and patch the module to use it.""" reg_path = tmp_path / "registry.yaml" registry = NodeRegistry( node=NodeConfig( hostname="test-node", castle_root=str(castle_root), gateway_port=9000, ), deployed={ NodeRegistry.key("service", "test-svc"): Deployment( manager="systemd", launcher="python", run_cmd=["uv", "run", "test-svc"], env={ "TEST_SVC_PORT": "19000", "TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc", }, description="Test service", name="test-svc", kind="service", port=19000, health_path="/health", subdomain="test-svc", managed=True, ), # A deployed tool (path) — must NOT leak into the /services list. NodeRegistry.key("tool", "test-tool"): Deployment( manager="path", run_cmd=[], description="Test tool", name="test-tool", kind="tool", ), }, ) save_registry(registry, reg_path) # Patch the registry path and helper functions import castle_core.registry as reg_mod import castle_api.routes as routes_mod import castle_api.services as services_mod import castle_api.nodes as nodes_mod import castle_api.stream as stream_mod import castle_api.config_editor as config_editor_mod original_path = reg_mod.REGISTRY_PATH reg_mod.REGISTRY_PATH = reg_path def _get_registry() -> NodeRegistry: from castle_core.registry import load_registry return load_registry(reg_path) def _get_castle_root() -> Path | None: return castle_root # Save originals and patch everywhere these are imported originals = { "api_config.get_registry": api_config.get_registry, "api_config.get_castle_root": api_config.get_castle_root, "routes.get_registry": routes_mod.get_registry, "routes.get_castle_root": routes_mod.get_castle_root, "services.get_registry": services_mod.get_registry, "services.get_castle_root": services_mod.get_castle_root, "nodes.get_registry": nodes_mod.get_registry, "stream.get_registry": stream_mod.get_registry, "config_editor.get_castle_root": config_editor_mod.get_castle_root, } for mod in [ api_config, routes_mod, services_mod, nodes_mod, stream_mod, config_editor_mod, ]: if hasattr(mod, "get_registry"): mod.get_registry = _get_registry if hasattr(mod, "get_castle_root"): mod.get_castle_root = _get_castle_root yield reg_path reg_mod.REGISTRY_PATH = original_path api_config.get_registry = originals["api_config.get_registry"] api_config.get_castle_root = originals["api_config.get_castle_root"] routes_mod.get_registry = originals["routes.get_registry"] routes_mod.get_castle_root = originals["routes.get_castle_root"] services_mod.get_registry = originals["services.get_registry"] services_mod.get_castle_root = originals["services.get_castle_root"] nodes_mod.get_registry = originals["nodes.get_registry"] stream_mod.get_registry = originals["stream.get_registry"] config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"] @pytest.fixture def client(registry_path: Path) -> Generator[TestClient, None, None]: """Create a test client with temporary registry.""" with TestClient(app) as client: yield client