feat: Add new tools and configurations for Castle platform

- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`.
- Added `pyrightconfig.json` for Python type checking configuration.
- Expanded `recommendations.md` with detailed scaling recommendations and project management strategies.
- Created shared `ruff.toml` for consistent linting across projects.
- Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools.
- Implemented `backup-collect` and `schedule` tools for system administration tasks.
- Enhanced `search` functionality with indexing and querying capabilities using Tantivy.
- Added comprehensive documentation for each tool, including usage examples and installation instructions.
This commit is contained in:
2026-02-20 16:41:19 -08:00
parent 0d35ac9ffd
commit f39a551aad
152 changed files with 21197 additions and 83 deletions

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

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

@@ -0,0 +1,68 @@
"""Shared fixtures for castle CLI 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",
"cwd": "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

171
cli/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_cli.config import (
CastleConfig,
load_config,
resolve_env_vars,
save_config,
)
from castle_cli.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.cwd == "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_cli.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_cli.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>"

137
cli/tests/test_create.py Normal file
View File

@@ -0,0 +1,137 @@
"""Tests for castle create command."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from castle_cli.config import load_config
from castle_cli.manifest import Role
class TestCreateCommand:
"""Tests for the create command."""
def test_create_service(self, castle_root: Path) -> None:
"""Create a new service project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
) as mock_save:
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="my-api",
type="service",
description="My API service",
port=9050,
)
result = run_create(args)
assert result == 0
project_dir = castle_root / "my-api"
assert project_dir.exists()
assert (project_dir / "pyproject.toml").exists()
assert (project_dir / "src" / "my_api" / "main.py").exists()
assert (project_dir / "src" / "my_api" / "config.py").exists()
assert (project_dir / "tests" / "conftest.py").exists()
assert (project_dir / "tests" / "test_health.py").exists()
assert (project_dir / "CLAUDE.md").exists()
# Verify registered as ComponentManifest
assert "my-api" in config.components
manifest = config.components["my-api"]
assert Role.SERVICE in manifest.roles
assert manifest.expose.http.internal.port == 9050
mock_save.assert_called_once()
def test_create_tool(self, castle_root: Path) -> None:
"""Create a new tool project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="my-tool", type="tool", description="My tool", port=None
)
result = run_create(args)
assert result == 0
project_dir = castle_root / "my-tool"
assert project_dir.exists()
assert (project_dir / "src" / "my_tool" / "main.py").exists()
assert (project_dir / "CLAUDE.md").exists()
assert "my-tool" in config.components
manifest = config.components["my-tool"]
assert Role.TOOL in manifest.roles
def test_create_library(self, castle_root: Path) -> None:
"""Create a new library project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="my-lib", type="library", description="My library", port=None
)
result = run_create(args)
assert result == 0
project_dir = castle_root / "my-lib"
assert project_dir.exists()
assert (project_dir / "src" / "my_lib" / "__init__.py").exists()
assert (project_dir / "CLAUDE.md").exists()
assert "my-lib" in config.components
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails."""
with patch("castle_cli.commands.create.load_config") as mock_load:
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="test-svc",
type="service",
description="Duplicate",
port=None,
)
result = run_create(args)
assert result == 1
def test_create_auto_port(self, castle_root: Path) -> None:
"""Service creation auto-assigns next available port."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="auto-port-svc",
type="service",
description="Auto port",
port=None,
)
run_create(args)
manifest = config.components["auto-port-svc"]
port = manifest.expose.http.internal.port
# Port 18000 is gateway, 19000 is test-svc, so next should be 9001+
assert port is not None
assert port not in (18000, 19000)

60
cli/tests/test_gateway.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for castle gateway command."""
from __future__ import annotations
from pathlib import Path
from castle_cli.commands.gateway import _generate_caddyfile
from castle_cli.config import load_config
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 / "dashboard" / "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

148
cli/tests/test_info.py Normal file
View File

@@ -0,0 +1,148 @@
"""Tests for castle info command."""
from __future__ import annotations
import json
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
class TestInfoCommand:
"""Tests for the info command."""
def test_info_service(self, castle_root: Path, capsys) -> None:
"""Show info for a service."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=False))
assert result == 0
output = capsys.readouterr().out
assert "test-svc" in output
assert "service" in output
assert "19000" in output
def test_info_tool(self, castle_root: Path, capsys) -> None:
"""Show info for a tool."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-tool", json=False))
assert result == 0
output = capsys.readouterr().out
assert "test-tool" in output
assert "tool" in output
def test_info_not_found(self, castle_root: Path, capsys) -> None:
"""Info for nonexistent component returns error."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="nope", json=False))
assert result == 1
output = capsys.readouterr().out
assert "not found" in output
def test_info_json(self, castle_root: Path, capsys) -> None:
"""--json produces valid JSON with manifest fields."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=True))
assert result == 0
output = capsys.readouterr().out
data = json.loads(output)
assert data["id"] == "test-svc"
assert "service" in data["roles"]
assert data["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_claude_md(self, castle_root: Path, capsys) -> None:
"""Info shows CLAUDE.md content when present."""
project_dir = castle_root / "test-svc"
project_dir.mkdir(exist_ok=True)
(project_dir / "CLAUDE.md").write_text("# Test Service\n\nSome docs here.")
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=False))
assert result == 0
output = capsys.readouterr().out
assert "Some docs here" in output
def test_info_json_includes_claude_md(self, castle_root: Path, capsys) -> None:
"""--json includes claude_md field when CLAUDE.md exists."""
project_dir = castle_root / "test-svc"
project_dir.mkdir(exist_ok=True)
(project_dir / "CLAUDE.md").write_text("# Docs\n")
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=True))
assert result == 0
data = json.loads(capsys.readouterr().out)
assert "claude_md" in data
assert "# Docs" in data["claude_md"]
def test_info_shows_env(self, castle_root: Path, capsys) -> None:
"""Info displays environment variables."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=False))
assert result == 0
output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_roles(self, castle_root: Path, capsys) -> None:
"""Info displays derived roles."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=False))
assert result == 0
output = capsys.readouterr().out
assert "roles" in output
assert "service" in output

75
cli/tests/test_list.py Normal file
View File

@@ -0,0 +1,75 @@
"""Tests for castle list command."""
from __future__ import annotations
import json
from argparse import Namespace
from pathlib import Path
from unittest.mock import patch
from castle_cli.commands.list_cmd import run_list
class TestListCommand:
"""Tests for the list command."""
def test_list_all(self, castle_root: Path, capsys: object) -> None:
"""List all components."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role=None, json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-svc" in captured.out
assert "test-tool" in captured.out
def test_list_filter_role(self, castle_root: Path, capsys: object) -> None:
"""List filtered by role."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role="tool", json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-tool" in captured.out
assert "test-svc" not in captured.out
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None:
"""List filtered to services."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role="service", json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-svc" in captured.out
assert "test-tool" not in captured.out
def test_list_json(self, castle_root: Path, capsys: object) -> None:
"""List output as JSON."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role=None, json=True)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
data = json.loads(captured.out)
names = [p["name"] for p in data]
assert "test-svc" in names
assert "test-tool" in names
svc = next(p for p in data if p["name"] == "test-svc")
assert "roles" in svc
assert "service" in svc["roles"]

195
cli/tests/test_manifest.py Normal file
View File

@@ -0,0 +1,195 @@
"""Tests for castle manifest — role derivation, validation."""
from __future__ import annotations
import pytest
from castle_cli.manifest import (
BuildSpec,
CaddySpec,
ComponentManifest,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
Role,
RunCommand,
RunContainer,
RunPythonUvTool,
RunRemote,
SystemdSpec,
ToolSpec,
ToolType,
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(
tool_type=ToolType.PYTHON_UV,
category="document",
source="tools/document/docx2md.py",
entry_point="tools.document.docx2md:main",
),
)
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(category="misc"),
)
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"

57
cli/tests/test_service.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests for castle service command."""
from __future__ import annotations
from pathlib import Path
from castle_cli.commands.service import _generate_unit, _unit_name
from castle_cli.config import load_config
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