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).
304 lines
10 KiB
Python
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
|