Files
wild-pc/castle-api/tests/conftest.py
Paul Payne 5beebfd739 core: drop pre-migration read-compat now both nodes are current-format
Both machines (civil, primer) are on the current on-disk layout, so the
read-compat for older formats is dead weight. Remove it:

- config loader: only deployments/<kind>/<name>.yaml (drop flat deployments/*.yaml
  and the services/+jobs/ split); drop the run.runner→manager normalizer.
- registry loader: require composite <kind>/<name> keys + manager/kind (registry.yaml
  is regenerated every apply, so it's always current).
- manifest: drop the proxy/public→reach legacy input and the component→program alias
  (the derived proxy/public read accessors stay). reach field defaults already match.
- config_editor: parse incoming specs directly (frontend sends the current shape).
- save_config: drop the flat-file migration cleanup.

Bootstrap seeds move to bootstrap/deployments/<kind>/ and install.sh seeds that
per-kind tree directly. The legacy→current translation for terse test fixtures
moves into each test conftest (out of production). All suites green (362).
2026-07-07 09:16:03 -07:00

304 lines
10 KiB
Python

"""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/<name>.yaml, and deployments/<kind>/<name>.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