refactor: Migrate CLI and API components to use castle-core for configuration and manifest handling

This commit is contained in:
2026-02-22 22:54:38 -08:00
parent 6d0332e32b
commit 41d4c574cb
31 changed files with 1699 additions and 904 deletions

0
core/tests/__init__.py Normal file
View File

68
core/tests/conftest.py Normal file
View File

@@ -0,0 +1,68 @@
"""Shared fixtures for castle core tests."""
from __future__ import annotations
from collections.abc import Generator
from pathlib import Path
import pytest
import yaml
@pytest.fixture
def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary castle root with castle.yaml."""
castle_yaml = tmp_path / "castle.yaml"
config = {
"gateway": {"port": 18000},
"components": {
"test-svc": {
"description": "Test service",
"run": {
"runner": "python_uv_tool",
"tool": "test-svc",
"working_dir": "test-svc",
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
},
"expose": {
"http": {
"internal": {"port": 19000},
"health_path": "/health",
}
},
"proxy": {
"caddy": {"path_prefix": "/test-svc"},
},
"manage": {
"systemd": {},
},
},
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
},
},
}
castle_yaml.write_text(yaml.dump(config, default_flow_style=False))
# Create project directories
svc_dir = tmp_path / "test-svc"
svc_dir.mkdir()
(svc_dir / "pyproject.toml").write_text("[project]\nname = 'test-svc'\n")
tool_dir = tmp_path / "test-tool"
tool_dir.mkdir()
yield tmp_path
@pytest.fixture
def castle_home(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary ~/.castle directory."""
home = tmp_path / ".castle"
home.mkdir()
(home / "generated").mkdir()
(home / "secrets").mkdir()
yield home

View File

@@ -0,0 +1,60 @@
"""Tests for Caddyfile generation."""
from __future__ import annotations
from pathlib import Path
from castle_core.config import load_config
from castle_core.generators.caddyfile import generate_caddyfile
class TestCaddyfileGeneration:
"""Tests for Caddyfile generation."""
def test_contains_gateway_port(self, castle_root: Path) -> None:
"""Caddyfile uses the configured gateway port."""
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
assert ":18000 {" in caddyfile
def test_contains_service_routes(self, castle_root: Path) -> None:
"""Caddyfile has reverse proxy routes for services with proxy.caddy."""
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
assert "handle_path /test-svc/*" in caddyfile
assert "reverse_proxy" in caddyfile
assert "19000" in caddyfile
def test_skips_tools(self, castle_root: Path) -> None:
"""Tools without proxy are not in Caddyfile."""
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
assert "test-tool" not in caddyfile
def test_fallback_when_no_dist(self, castle_root: Path) -> None:
"""Uses fallback dashboard path when dist/ doesn't exist."""
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
# No dashboard/dist exists in tmp, so should use fallback
assert "handle / {" in caddyfile
assert "file_server" in caddyfile
def test_spa_serving_when_dist_exists(self, castle_root: Path) -> None:
"""Serves SPA with try_files when dashboard/dist exists."""
# Create a dashboard/dist with index.html
dist = castle_root / "app" / "dist"
dist.mkdir(parents=True)
(dist / "index.html").write_text("<html></html>")
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
assert "try_files {path} /index.html" in caddyfile
assert str(dist) in caddyfile
def test_proxy_routes_before_dashboard(self, castle_root: Path) -> None:
"""Service proxy routes appear before the dashboard catch-all."""
config = load_config(castle_root)
caddyfile = generate_caddyfile(config)
proxy_pos = caddyfile.index("handle_path")
handle_pos = caddyfile.index("handle /")
assert proxy_pos < handle_pos

171
core/tests/test_config.py Normal file
View File

@@ -0,0 +1,171 @@
"""Tests for castle configuration loading."""
from __future__ import annotations
from pathlib import Path
import pytest
from castle_core.config import (
CastleConfig,
load_config,
resolve_env_vars,
save_config,
)
from castle_core.manifest import ComponentManifest, Role
class TestLoadConfig:
"""Tests for loading castle.yaml."""
def test_load_basic(self, castle_root: Path) -> None:
"""Load a castle.yaml."""
config = load_config(castle_root)
assert isinstance(config, CastleConfig)
assert config.gateway.port == 18000
assert "test-svc" in config.components
assert "test-tool" in config.components
def test_load_produces_manifests(self, castle_root: Path) -> None:
"""Components are ComponentManifest objects."""
config = load_config(castle_root)
assert isinstance(config.components["test-svc"], ComponentManifest)
assert isinstance(config.components["test-tool"], ComponentManifest)
def test_service_roles(self, castle_root: Path) -> None:
"""Service with expose.http gets SERVICE role."""
config = load_config(castle_root)
svc = config.components["test-svc"]
assert Role.SERVICE in svc.roles
def test_tool_roles(self, castle_root: Path) -> None:
"""Tool with install.path gets TOOL role."""
config = load_config(castle_root)
tool = config.components["test-tool"]
assert Role.TOOL in tool.roles
def test_service_expose(self, castle_root: Path) -> None:
"""Service has correct expose spec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
assert svc.expose.http.internal.port == 19000
assert svc.expose.http.health_path == "/health"
def test_service_proxy(self, castle_root: Path) -> None:
"""Service has correct proxy spec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
assert svc.proxy.caddy.path_prefix == "/test-svc"
def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
assert svc.run.runner == "python_uv_tool"
assert svc.run.tool == "test-svc"
assert svc.run.working_dir == "test-svc"
def test_tool_no_run(self, castle_root: Path) -> None:
"""Tool without run block has no run spec."""
config = load_config(castle_root)
tool = config.components["test-tool"]
assert tool.run is None
def test_services_property(self, castle_root: Path) -> None:
"""Services property filters to SERVICE role."""
config = load_config(castle_root)
assert "test-svc" in config.services
assert "test-tool" not in config.services
def test_tools_property(self, castle_root: Path) -> None:
"""Tools property filters to TOOL role."""
config = load_config(castle_root)
assert "test-tool" in config.tools
assert "test-svc" not in config.tools
def test_managed_property(self, castle_root: Path) -> None:
"""Managed property returns systemd-managed components."""
config = load_config(castle_root)
assert "test-svc" in config.managed
assert "test-tool" not in config.managed
def test_missing_config_raises(self, tmp_path: Path) -> None:
"""Missing castle.yaml raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
load_config(tmp_path)
class TestSaveConfig:
"""Tests for saving castle.yaml."""
def test_round_trip(self, castle_root: Path) -> None:
"""Load and save should produce equivalent config."""
config = load_config(castle_root)
save_config(config)
config2 = load_config(castle_root)
assert config2.gateway.port == config.gateway.port
assert set(config2.components.keys()) == set(config.components.keys())
def test_save_adds_component(self, castle_root: Path) -> None:
"""Adding a component and saving persists it."""
config = load_config(castle_root)
config.components["new-lib"] = ComponentManifest(
id="new-lib", description="A new library"
)
save_config(config)
config2 = load_config(castle_root)
assert "new-lib" in config2.components
assert config2.components["new-lib"].description == "A new library"
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
"""Roundtrip preserves manage.systemd even with all defaults."""
config = load_config(castle_root)
save_config(config)
config2 = load_config(castle_root)
assert "test-svc" in config2.managed
class TestResolveEnvVars:
"""Tests for environment variable resolution."""
def test_no_vars(self) -> None:
"""Plain values pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "plain_value"}
resolved = resolve_env_vars(env, manifest)
assert resolved["MY_VAR"] == "plain_value"
def test_unrecognized_vars_preserved(self) -> None:
"""Non-secret ${} references pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "${unknown_var}"}
resolved = resolve_env_vars(env, manifest)
assert resolved["MY_VAR"] == "${unknown_var}"
def test_resolve_secret(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""${secret:NAME} resolves from secrets directory."""
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
(secrets_dir / "API_KEY").write_text("my-secret-key\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:API_KEY}"}
resolved = resolve_env_vars(env, manifest)
assert resolved["API_KEY"] == "my-secret-key"
def test_resolve_missing_secret(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Missing secret returns placeholder."""
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir()
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:NONEXISTENT}"}
resolved = resolve_env_vars(env, manifest)
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>"

189
core/tests/test_manifest.py Normal file
View File

@@ -0,0 +1,189 @@
"""Tests for castle manifest — role derivation, validation."""
from __future__ import annotations
import pytest
from castle_core.manifest import (
BuildSpec,
CaddySpec,
ComponentManifest,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
Role,
RunCommand,
RunContainer,
RunPythonUvTool,
RunRemote,
SystemdSpec,
ToolSpec,
TriggerSchedule,
)
class TestRoleDerivation:
"""Tests for computed role derivation."""
def test_service_from_expose_http(self) -> None:
"""Component with expose.http gets SERVICE role."""
m = ComponentManifest(
id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
)
assert Role.SERVICE in m.roles
def test_tool_from_install_path(self) -> None:
"""Component with install.path gets TOOL role."""
m = ComponentManifest(
id="mytool",
install=InstallSpec(path=PathInstallSpec(alias="mytool")),
)
assert Role.TOOL in m.roles
def test_worker_from_systemd_without_http(self) -> None:
"""Component managed by systemd but no HTTP gets WORKER role."""
m = ComponentManifest(
id="worker",
run=RunCommand(runner="command", argv=["worker-bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert Role.WORKER in m.roles
assert Role.SERVICE not in m.roles
def test_container_role(self) -> None:
"""Container runner gets CONTAINERIZED role."""
m = ComponentManifest(
id="container",
run=RunContainer(runner="container", image="redis:7"),
)
assert Role.CONTAINERIZED in m.roles
def test_remote_role(self) -> None:
"""Remote runner gets REMOTE role."""
m = ComponentManifest(
id="remote",
run=RunRemote(runner="remote", base_url="http://example.com"),
)
assert Role.REMOTE in m.roles
def test_job_from_schedule_trigger(self) -> None:
"""Component with schedule trigger gets JOB role."""
m = ComponentManifest(
id="job",
run=RunCommand(runner="command", argv=["backup"]),
triggers=[TriggerSchedule(cron="0 * * * *")],
)
assert Role.JOB in m.roles
def test_frontend_from_build(self) -> None:
"""Component with build outputs gets FRONTEND role."""
m = ComponentManifest(
id="frontend",
run=RunCommand(runner="command", argv=["serve"]),
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
)
assert Role.FRONTEND in m.roles
def test_tool_from_tool_spec(self) -> None:
"""Component with tool spec gets TOOL role."""
m = ComponentManifest(
id="docx2md",
tool=ToolSpec(source="docx2md/"),
)
assert Role.TOOL in m.roles
def test_tool_spec_without_install(self) -> None:
"""Tool spec alone is enough for TOOL role, no install.path needed."""
m = ComponentManifest(
id="my-tool",
tool=ToolSpec(),
)
assert Role.TOOL in m.roles
def test_fallback_to_tool(self) -> None:
"""Component with no indicators defaults to TOOL."""
m = ComponentManifest(id="bare")
assert m.roles == [Role.TOOL]
def test_multiple_roles(self) -> None:
"""Component can have multiple roles."""
m = ComponentManifest(
id="multi",
run=RunPythonUvTool(runner="python_uv_tool", tool="multi"),
expose=ExposeSpec(
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
install=InstallSpec(path=PathInstallSpec(alias="multi")),
)
assert Role.SERVICE in m.roles
assert Role.TOOL in m.roles
def test_systemd_with_http_is_service_not_worker(self) -> None:
"""Systemd + HTTP = SERVICE, not WORKER."""
m = ComponentManifest(
id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert Role.SERVICE in m.roles
assert Role.WORKER not in m.roles
class TestConsistencyValidation:
"""Tests for model validation."""
def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid."""
with pytest.raises(ValueError, match="manage.systemd cannot be enabled for runner=remote"):
ComponentManifest(
id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"),
manage=ManageSpec(systemd=SystemdSpec()),
)
def test_no_run_is_valid(self) -> None:
"""Component with no run spec is valid (registration-only)."""
m = ComponentManifest(id="reg-only", description="Just registered")
assert m.run is None
assert m.roles == [Role.TOOL]
class TestModelSerialization:
"""Tests for model_dump behavior."""
def test_dump_excludes_none(self) -> None:
"""model_dump with exclude_none drops None fields."""
m = ComponentManifest(id="test", description="Test")
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
assert "description" in data
assert "run" not in data
assert "manage" not in data
def test_dump_service(self) -> None:
"""Full service manifest serializes correctly."""
m = ComponentManifest(
id="svc",
description="A service",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc", cwd="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health"
)
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
manage=ManageSpec(systemd=SystemdSpec()),
)
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
assert data["run"]["runner"] == "python_uv_tool"
assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"]["caddy"]["path_prefix"] == "/svc"

View File

@@ -0,0 +1,57 @@
"""Tests for systemd unit generation."""
from __future__ import annotations
from pathlib import Path
from castle_core.config import load_config
from castle_core.generators.systemd import generate_unit, unit_name
class TestUnitName:
"""Tests for systemd unit naming."""
def test_unit_name_format(self) -> None:
"""Unit names follow castle-<name>.service pattern."""
assert unit_name("central-context") == "castle-central-context.service"
assert unit_name("my-svc") == "castle-my-svc.service"
class TestUnitGeneration:
"""Tests for systemd unit file generation."""
def test_contains_description(self, castle_root: Path) -> None:
"""Unit file has service description."""
config = load_config(castle_root)
manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest)
assert "Description=Castle: Test service" in unit
def test_contains_working_dir(self, castle_root: Path) -> None:
"""Unit file has correct working directory."""
config = load_config(castle_root)
manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest)
assert f"WorkingDirectory={castle_root / 'test-svc'}" in unit
def test_contains_environment(self, castle_root: Path) -> None:
"""Unit file has environment variables."""
config = load_config(castle_root)
manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest)
expected_data_dir = str(castle_root / "data" / "test-svc")
assert f"Environment=TEST_SVC_DATA_DIR={expected_data_dir}" in unit
def test_contains_restart_policy(self, castle_root: Path) -> None:
"""Unit file has restart configuration."""
config = load_config(castle_root)
manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest)
assert "Restart=on-failure" in unit
def test_uses_uv_run(self, castle_root: Path) -> None:
"""Unit file ExecStart uses uv run for python_uv_tool."""
config = load_config(castle_root)
manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest)
assert "run test-svc" in unit