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:
0
cli/tests/__init__.py
Normal file
0
cli/tests/__init__.py
Normal file
68
cli/tests/conftest.py
Normal file
68
cli/tests/conftest.py
Normal 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
171
cli/tests/test_config.py
Normal 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
137
cli/tests/test_create.py
Normal 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
60
cli/tests/test_gateway.py
Normal 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
148
cli/tests/test_info.py
Normal 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
75
cli/tests/test_list.py
Normal 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
195
cli/tests/test_manifest.py
Normal 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
57
cli/tests/test_service.py
Normal 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
|
||||
Reference in New Issue
Block a user