refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling

This commit is contained in:
2026-02-22 23:19:16 -08:00
parent 033a76ccfd
commit d52d8829ba
37 changed files with 1271 additions and 414 deletions

View File

@@ -4,11 +4,13 @@ from pathlib import Path
from pydantic_settings import BaseSettings
from castle_core.config import CastleConfig, load_config
from castle_core.registry import NodeRegistry, load_registry
class Settings(BaseSettings):
"""Service settings loaded from environment variables."""
castle_root: Path = Path("/data/repos/castle")
host: str = "0.0.0.0"
port: int = 9020
@@ -19,3 +21,32 @@ class Settings(BaseSettings):
settings = Settings()
def get_registry() -> NodeRegistry:
"""Load the node registry. Raises if not found."""
return load_registry()
def get_castle_root() -> Path | None:
"""Get the castle repo root from the registry, if available."""
try:
registry = load_registry()
if registry.node.castle_root:
return Path(registry.node.castle_root)
except (FileNotFoundError, ValueError):
pass
return None
def get_config() -> CastleConfig:
"""Load castle.yaml via the registry's castle_root.
Raises FileNotFoundError if repo not available.
"""
root = get_castle_root()
if root is None:
raise FileNotFoundError(
"Castle repo not available. Set castle_root in registry."
)
return load_config(root)

View File

@@ -9,10 +9,10 @@ import yaml
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import load_config, save_config
from castle_core.config import save_config
from castle_core.manifest import ComponentManifest
from castle_api.config import settings
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"])
@@ -42,16 +42,29 @@ class ComponentConfigRequest(BaseModel):
config: dict
def _require_repo() -> None:
"""Raise 503 if repo is not available."""
if get_castle_root() is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Castle repo not available on this node.",
)
@router.get("", response_model=ConfigResponse)
def get_config() -> ConfigResponse:
def get_config_yaml() -> ConfigResponse:
"""Get the raw castle.yaml content."""
config_path = settings.castle_root / "castle.yaml"
_require_repo()
root = get_castle_root()
config_path = root / "castle.yaml"
return ConfigResponse(yaml_content=config_path.read_text())
@router.put("", response_model=ConfigSaveResponse)
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
"""Validate and save castle.yaml. Does NOT apply changes."""
_require_repo()
root = get_castle_root()
errors: list[str] = []
# Parse YAML
@@ -87,7 +100,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
)
# Backup and save
config_path = settings.castle_root / "castle.yaml"
config_path = root / "castle.yaml"
backup_path = config_path.with_suffix(".yaml.bak")
shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content)
@@ -98,6 +111,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
@router.put("/components/{name}")
def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
_require_repo()
# Validate
try:
comp_data = dict(request.config)
@@ -109,7 +124,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
detail=f"Invalid component config: {e}",
)
config = load_config(settings.castle_root)
config = get_config()
config.components[name] = ComponentManifest.model_validate(
{**request.config, "id": name}
)
@@ -120,7 +135,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
@router.delete("/components/{name}")
def delete_component(name: str) -> dict:
"""Remove a component from castle.yaml."""
config = load_config(settings.castle_root)
config = get_config()
if name not in config.components:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -133,13 +148,15 @@ def delete_component(name: str) -> dict:
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Apply config: regenerate systemd units for managed services + reload gateway."""
config = load_config(settings.castle_root)
"""Apply config: restart managed services + regenerate and reload gateway."""
registry = get_registry()
actions: list[str] = []
errors: list[str] = []
# Regenerate and restart managed services
for name in config.managed:
# Restart managed services
for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
unit = f"castle-{name}.service"
ok, output = await _systemctl("restart", unit)
if ok:
@@ -149,17 +166,17 @@ async def apply_config() -> ApplyResponse:
# Reload gateway
from castle_core.config import GENERATED_DIR, ensure_dirs
from castle_core.generators import generate_caddyfile
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile(config))
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
actions.append("Generated Caddyfile")
if shutil.which("caddy"):
ok, output = await _run("caddy", "reload",
"--config", str(caddyfile_path),
"--adapter", "caddyfile")
ok, output = await _run(
"caddy", "reload", "--config", str(caddyfile_path), "--adapter", "caddyfile"
)
if ok:
actions.append("Reloaded gateway")
else:
@@ -171,7 +188,10 @@ async def apply_config() -> ApplyResponse:
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, unit,
"systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

View File

@@ -7,23 +7,18 @@ import time
import httpx
from castle_core.config import CastleConfig
from castle_core.registry import NodeRegistry
from castle_api.models import HealthStatus
async def check_all_health(config: CastleConfig) -> list[HealthStatus]:
"""Check health of all components with expose.http and a health_path."""
async def check_all_health(registry: NodeRegistry) -> list[HealthStatus]:
"""Check health of all deployed components with a port and health_path."""
targets: list[tuple[str, str]] = []
for name, manifest in config.components.items():
if not (manifest.expose and manifest.expose.http):
for name, deployed in registry.deployed.items():
if not deployed.port or not deployed.health_path:
continue
http = manifest.expose.http
if not http.health_path:
continue
host = http.internal.host or "127.0.0.1"
port = http.internal.port
url = f"http://{host}:{port}{http.health_path}"
url = f"http://127.0.0.1:{deployed.port}{deployed.health_path}"
targets.append((name, url))
if not targets:

View File

@@ -42,7 +42,13 @@ async def get_logs(
# Static tail
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "--no-pager",
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -54,7 +60,14 @@ async def get_logs(
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
"""Stream journalctl -f output as SSE events."""
proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "-f", "--no-pager",
"journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"-f",
"--no-pager",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

View File

@@ -17,7 +17,12 @@ from castle_api.logs import router as logs_router
from castle_api.routes import router as dashboard_router
from castle_api.secrets import router as secrets_router
from castle_api.services import router as services_router
from castle_api.stream import close_all_subscribers, health_poll_loop, subscribe, unsubscribe
from castle_api.stream import (
close_all_subscribers,
health_poll_loop,
subscribe,
unsubscribe,
)
from castle_api.tools import router as tools_router
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.

View File

@@ -7,9 +7,9 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.config import load_config
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_api.config import settings
from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health
from castle_api.models import (
ComponentDetail,
@@ -22,8 +22,43 @@ from castle_api.models import (
router = APIRouter(tags=["dashboard"])
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
"""Build a ComponentSummary from a DeployedComponent."""
managed = deployed.managed
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = deployed.schedule is not None
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
# Check if tool is installed on PATH
installed: bool | None = None
if "tool" in deployed.roles:
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
roles=deployed.roles,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
managed=managed,
systemd=systemd_info,
schedule=deployed.schedule,
installed=installed,
)
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a manifest."""
"""Build a ComponentSummary from a manifest (for non-deployed components)."""
port = None
health_path = None
proxy_path = None
@@ -37,26 +72,25 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
)
# Systemd info for managed components
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers)
has_timer = any(
getattr(t, "type", None) == "schedule" for t in manifest.triggers
)
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
# Extract cron schedule from first schedule trigger, if any
schedule = None
for t in manifest.triggers:
if t.type == "schedule":
schedule = t.cron
break
# Infer runner — from run block or from tool source
runner = manifest.run.runner if manifest.run else None
if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source
@@ -65,7 +99,6 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
elif source_dir.is_file():
runner = "command"
# Check if tool is actually installed on PATH
installed: bool | None = None
if manifest.install and manifest.install.path:
alias = manifest.install.path.alias or name
@@ -91,53 +124,95 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
@router.get("/components", response_model=list[ComponentSummary])
def list_components() -> list[ComponentSummary]:
"""List all registered components."""
config = load_config(settings.castle_root)
return [
_summary_from_manifest(name, m, config.root)
for name, m in config.components.items()
]
"""List all components — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
summaries: list[ComponentSummary] = []
seen: set[str] = set()
# Deployed components from registry
for name, deployed in registry.deployed.items():
summaries.append(_summary_from_deployed(name, deployed))
seen.add(name)
# Non-deployed components from castle.yaml (if repo available)
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, manifest in config.components.items():
if name not in seen:
summaries.append(_summary_from_manifest(name, manifest, root))
except FileNotFoundError:
pass
return summaries
@router.get("/components/{name}", response_model=ComponentDetail)
def get_component(name: str) -> ComponentDetail:
"""Get detailed info for a single component."""
config = load_config(settings.castle_root)
if name not in config.components:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest, config.root)
raw = manifest.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
registry = get_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"roles": deployed.roles,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)
# Fall back to castle.yaml
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest, root)
raw = manifest.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
@router.get("/status", response_model=StatusResponse)
async def get_status() -> StatusResponse:
"""Get live health status for all exposed services."""
config = load_config(settings.castle_root)
statuses = await check_all_health(config)
"""Get live health status for all deployed services."""
registry = get_registry()
statuses = await check_all_health(registry)
return StatusResponse(statuses=statuses)
@router.get("/gateway", response_model=GatewayInfo)
def get_gateway() -> GatewayInfo:
"""Get gateway configuration summary."""
config = load_config(settings.castle_root)
registry = get_registry()
deployed_count = len(registry.deployed)
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
return GatewayInfo(
port=config.gateway.port,
component_count=len(config.components),
service_count=len(config.services),
managed_count=len(config.managed),
port=registry.node.gateway_port,
component_count=deployed_count,
service_count=service_count,
managed_count=managed_count,
)
@router.get("/gateway/caddyfile")
def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content."""
from castle_core.generators import generate_caddyfile
config = load_config(settings.castle_root)
return {"content": generate_caddyfile(config)}
registry = get_registry()
return {"content": generate_caddyfile_from_registry(registry)}

View File

@@ -8,9 +8,12 @@ import time
from fastapi import APIRouter, HTTPException, status
from starlette.responses import JSONResponse
from castle_core.config import load_config
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
)
from castle_api.config import settings
from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health
from castle_api.models import HealthStatus
from castle_api.stream import broadcast
@@ -24,7 +27,10 @@ SELF_NAME = "castle-api"
async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
"""Run a systemctl --user command. Returns (success, output)."""
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, unit,
"systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -36,7 +42,10 @@ async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
async def _get_unit_status(unit: str) -> str:
"""Get the active status of a systemd unit."""
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "is-active", unit,
"systemctl",
"--user",
"is-active",
unit,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -45,9 +54,9 @@ async def _get_unit_status(unit: str) -> str:
def _validate_managed(name: str) -> None:
"""Raise 404 if the component isn't systemd-managed."""
config = load_config(settings.castle_root)
if name not in config.managed:
"""Raise 404 if the component isn't managed in the registry."""
registry = get_registry()
if name not in registry.deployed or not registry.deployed[name].managed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
@@ -58,25 +67,29 @@ async def _broadcast_health_with_override(
override_name: str, override_status: str
) -> None:
"""Run health checks but override one component's status from systemd."""
config = load_config(settings.castle_root)
statuses = await check_all_health(config)
registry = get_registry()
statuses = await check_all_health(registry)
# Replace the overridden component's status with the systemd truth
result = []
for s in statuses:
if s.id == override_name:
result.append(HealthStatus(
id=override_name,
status="down" if override_status != "active" else "up",
latency_ms=None,
))
result.append(
HealthStatus(
id=override_name,
status="down" if override_status != "active" else "up",
latency_ms=None,
)
)
else:
result.append(s)
await broadcast("health", {
"statuses": [s.model_dump() for s in result],
"timestamp": time.time(),
})
await broadcast(
"health",
{
"statuses": [s.model_dump() for s in result],
"timestamp": time.time(),
},
)
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
@@ -104,7 +117,6 @@ async def _do_action(name: str, action: str) -> JSONResponse:
if not ok:
raise HTTPException(status_code=500, detail=output or f"Failed to {action}")
# Broadcast immediately with systemd status as the source of truth
await _broadcast_health_with_override(name, unit_status)
return JSONResponse(
@@ -115,13 +127,25 @@ async def _do_action(name: str, action: str) -> JSONResponse:
@router.get("/{name}/unit")
def get_unit(name: str) -> dict[str, str | None]:
"""Return the generated systemd unit file(s) for a managed component."""
from castle_core.generators import generate_timer, generate_unit
_validate_managed(name)
config = load_config(settings.castle_root)
manifest = config.managed[name]
unit = generate_unit(config, name, manifest)
timer = generate_timer(name, manifest)
registry = get_registry()
deployed = registry.deployed[name]
# Get systemd spec from manifest if repo available
systemd_spec = None
root = get_castle_root()
manifest = None
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, manifest) if manifest else None
return {"service": unit, "timer": timer}

View File

@@ -7,9 +7,7 @@ import json
import logging
import time
from castle_core.config import load_config
from castle_api.config import settings
from castle_api.config import get_registry
from castle_api.health import check_all_health
logger = logging.getLogger(__name__)
@@ -60,12 +58,15 @@ async def health_poll_loop(interval: float = 10.0) -> None:
"""Background task that polls health and broadcasts updates."""
while True:
try:
config = load_config(settings.castle_root)
statuses = await check_all_health(config)
await broadcast("health", {
"statuses": [s.model_dump() for s in statuses],
"timestamp": time.time(),
})
registry = get_registry()
statuses = await check_all_health(registry)
await broadcast(
"health",
{
"statuses": [s.model_dump() for s in statuses],
"timestamp": time.time(),
},
)
except Exception:
logger.exception("Health poll failed")
await asyncio.sleep(interval)

View File

@@ -7,20 +7,23 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.config import load_config
from castle_core.manifest import ComponentManifest
from castle_api.config import settings
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary:
def _tool_summary(
name: str, manifest: ComponentManifest, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a manifest that has a tool spec."""
t = manifest.tool
assert t is not None
installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable)
installed = bool(
manifest.install and manifest.install.path and manifest.install.path.enable
)
# Infer runner from run block or source directory
runner = manifest.run.runner if manifest.run else None
@@ -44,12 +47,15 @@ def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = No
@router.get("/tools", response_model=list[ToolSummary])
def list_tools() -> list[ToolSummary]:
"""List all registered tools."""
config = load_config(settings.castle_root)
"""List all registered tools (requires repo access)."""
config = get_config()
tools = {k: v for k, v in config.components.items() if v.tool}
return sorted(
[_tool_summary(name, manifest, config.root) for name, manifest in tools.items()],
[
_tool_summary(name, manifest, config.root)
for name, manifest in tools.items()
],
key=lambda t: t.id,
)
@@ -57,7 +63,7 @@ def list_tools() -> list[ToolSummary]:
@router.get("/tools/{name}", response_model=ToolDetail)
def get_tool(name: str) -> ToolDetail:
"""Get detailed info for a single tool."""
config = load_config(settings.castle_root)
config = get_config()
if name not in config.components:
raise HTTPException(
@@ -79,20 +85,31 @@ def get_tool(name: str) -> ToolDetail:
@router.post("/tools/{name}/install")
async def install_tool(name: str) -> dict:
"""Install a tool to PATH via uv tool install."""
config = load_config(settings.castle_root)
config = get_config()
if name not in config.components:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source to install")
raise HTTPException(
status_code=400, detail=f"'{name}' has no tool source to install"
)
source_dir = config.root / manifest.tool.source
if not (source_dir / "pyproject.toml").exists():
raise HTTPException(status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}")
raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
)
proc = await asyncio.create_subprocess_exec(
"uv", "tool", "install", "--editable", str(source_dir), "--force",
"uv",
"tool",
"install",
"--editable",
str(source_dir),
"--force",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -108,9 +125,11 @@ async def install_tool(name: str) -> dict:
@router.post("/tools/{name}/uninstall")
async def uninstall_tool(name: str) -> dict:
"""Uninstall a tool from PATH via uv tool uninstall."""
config = load_config(settings.castle_root)
config = get_config()
if name not in config.components:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
@@ -118,17 +137,20 @@ async def uninstall_tool(name: str) -> dict:
# uv tool uninstall uses the package name from pyproject.toml
source_dir = config.root / manifest.tool.source
# Try to read the package name; fall back to the source dir name
pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml"
if pyproject.exists():
import tomllib
with open(pyproject, "rb") as f:
data = tomllib.load(f)
pkg_name = data.get("project", {}).get("name", pkg_name)
proc = await asyncio.create_subprocess_exec(
"uv", "tool", "uninstall", pkg_name,
"uv",
"tool",
"uninstall",
pkg_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

View File

@@ -7,8 +7,14 @@ import pytest
import yaml
from fastapi.testclient import TestClient
from castle_api.config import settings
import castle_api.config as api_config
from castle_api.main import app
from castle_core.registry import (
DeployedComponent,
NodeConfig,
NodeRegistry,
save_registry,
)
@pytest.fixture
@@ -20,10 +26,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"components": {
"test-svc": {
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python_uv_tool",
"tool": "test-svc",
"cwd": "test-svc",
},
"expose": {
"http": {
@@ -52,15 +58,67 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
},
}
castle_yaml.write_text(yaml.dump(config, default_flow_style=False))
original = settings.castle_root
settings.castle_root = tmp_path
yield tmp_path
settings.castle_root = original
@pytest.fixture
def client(castle_root: Path) -> Generator[TestClient, None, None]:
"""Create a test client pointing to temporary castle root."""
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={
"test-svc": DeployedComponent(
runner="python_uv_tool",
run_cmd=["uv", "run", "test-svc"],
env={
"TEST_SVC_PORT": "19000",
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
},
description="Test service",
roles=["service"],
port=19000,
health_path="/health",
proxy_path="/test-svc",
managed=True,
),
},
)
save_registry(registry, reg_path)
# Patch the registry path and helper functions
import castle_core.registry as reg_mod
original_path = reg_mod.REGISTRY_PATH
reg_mod.REGISTRY_PATH = reg_path
original_get_registry = api_config.get_registry
original_get_castle_root = api_config.get_castle_root
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
api_config.get_registry = _get_registry
api_config.get_castle_root = _get_castle_root
yield reg_path
reg_mod.REGISTRY_PATH = original_path
api_config.get_registry = original_get_registry
api_config.get_castle_root = original_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

View File

@@ -55,7 +55,7 @@ class TestComponentDetail:
data = response.json()
assert data["id"] == "test-svc"
assert "manifest" in data
assert data["manifest"]["run"]["runner"] == "python_uv_tool"
assert data["manifest"]["runner"] == "python_uv_tool"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component."""
@@ -67,11 +67,12 @@ class TestGateway:
"""Gateway info endpoint tests."""
def test_gateway_info(self, client: TestClient) -> None:
"""Returns gateway configuration."""
"""Returns gateway configuration from registry."""
response = client.get("/gateway")
assert response.status_code == 200
data = response.json()
assert data["port"] == 9000
assert data["component_count"] == 3
# Registry has 1 deployed component (test-svc)
assert data["component_count"] == 1
assert data["service_count"] == 1
assert data["managed_count"] == 1

View File

@@ -1,7 +1,5 @@
"""Tests for tools endpoints."""
from pathlib import Path
from fastapi.testclient import TestClient