refactor: Migrate CLI and API components to use castle-core for configuration and manifest handling
This commit is contained in:
25
core/pyproject.toml
Normal file
25
core/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "castle-core"
|
||||
version = "0.1.0"
|
||||
description = "Castle platform core library - manifest models, config, and generators"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pyyaml>=6.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/castle_core"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pyyaml>=6.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["castle_core"]
|
||||
3
core/src/castle_core/__init__.py
Normal file
3
core/src/castle_core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Castle core library - manifest models, configuration, and generators."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
217
core/src/castle_core/config.py
Normal file
217
core/src/castle_core/config.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Castle configuration and registry management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.manifest import ComponentManifest, Role
|
||||
|
||||
|
||||
def find_castle_root() -> Path:
|
||||
"""Find the castle repository root by walking up from cwd looking for castle.yaml."""
|
||||
current = Path.cwd()
|
||||
while current != current.parent:
|
||||
if (current / "castle.yaml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Fallback: check if castle.yaml is in a well-known location
|
||||
default = Path("/data/repos/castle")
|
||||
if (default / "castle.yaml").exists():
|
||||
return default
|
||||
raise FileNotFoundError(
|
||||
"Could not find castle.yaml. Run castle from within the castle repository."
|
||||
)
|
||||
|
||||
|
||||
CASTLE_HOME = Path.home() / ".castle"
|
||||
GENERATED_DIR = CASTLE_HOME / "generated"
|
||||
SECRETS_DIR = CASTLE_HOME / "secrets"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewayConfig:
|
||||
"""Gateway configuration."""
|
||||
|
||||
port: int = 9000
|
||||
|
||||
|
||||
@dataclass
|
||||
class CastleConfig:
|
||||
"""Full castle configuration."""
|
||||
|
||||
root: Path
|
||||
gateway: GatewayConfig
|
||||
components: dict[str, ComponentManifest]
|
||||
|
||||
@property
|
||||
def services(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the SERVICE role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.SERVICE in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the TOOL role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.TOOL in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def workers(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the WORKER role."""
|
||||
return {
|
||||
k: v for k, v in self.components.items() if Role.WORKER in v.roles
|
||||
}
|
||||
|
||||
@property
|
||||
def managed(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components managed by systemd."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
if v.manage and v.manage.systemd and v.manage.systemd.enable
|
||||
}
|
||||
|
||||
|
||||
def resolve_env_vars(
|
||||
env: dict[str, str], manifest: ComponentManifest
|
||||
) -> dict[str, str]:
|
||||
"""Resolve ${secret:NAME} references in env values."""
|
||||
resolved = {}
|
||||
for key, value in env.items():
|
||||
|
||||
def replace_var(match: re.Match[str]) -> str:
|
||||
ref = match.group(1)
|
||||
if ref.startswith("secret:"):
|
||||
secret_name = ref[7:]
|
||||
return _read_secret(secret_name)
|
||||
return match.group(0)
|
||||
|
||||
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value)
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_secret(name: str) -> str:
|
||||
"""Read a secret from ~/.castle/secrets/<name>. Returns placeholder if not found."""
|
||||
secret_path = SECRETS_DIR / name
|
||||
if secret_path.exists():
|
||||
return secret_path.read_text().strip()
|
||||
return f"<MISSING_SECRET:{name}>"
|
||||
|
||||
|
||||
def _parse_component(name: str, data: dict) -> ComponentManifest:
|
||||
"""Parse a components: entry directly into a ComponentManifest."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return ComponentManifest.model_validate(data_copy)
|
||||
|
||||
|
||||
def load_config(root: Path | None = None) -> CastleConfig:
|
||||
"""Load castle.yaml and return parsed configuration."""
|
||||
if root is None:
|
||||
root = find_castle_root()
|
||||
|
||||
config_path = root / "castle.yaml"
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"Castle config not found: {config_path}")
|
||||
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
gateway_data = data.get("gateway", {})
|
||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||
|
||||
components: dict[str, ComponentManifest] = {}
|
||||
for name, comp_data in data.get("components", {}).items():
|
||||
components[name] = _parse_component(name, comp_data)
|
||||
|
||||
return CastleConfig(root=root, gateway=gateway, components=components)
|
||||
|
||||
|
||||
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
|
||||
"""Recursively remove empty lists and non-structural empty dicts."""
|
||||
if preserve_keys is None:
|
||||
preserve_keys = _STRUCTURAL_KEYS
|
||||
if isinstance(data, dict):
|
||||
cleaned = {}
|
||||
for k, v in data.items():
|
||||
v = _clean_for_yaml(v, preserve_keys)
|
||||
# Keep structural keys even if empty dict
|
||||
if k in preserve_keys and isinstance(v, dict):
|
||||
cleaned[k] = v
|
||||
continue
|
||||
# Skip empty collections
|
||||
if isinstance(v, (list, dict)) and not v:
|
||||
continue
|
||||
cleaned[k] = v
|
||||
return cleaned
|
||||
elif isinstance(data, list):
|
||||
return [_clean_for_yaml(item, preserve_keys) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
# Keys whose presence is structurally significant even with all-default values.
|
||||
# We serialize these as empty dicts `{}` so they survive a roundtrip.
|
||||
_STRUCTURAL_KEYS = {"manage", "systemd", "install", "path", "tool", "expose", "proxy", "caddy"}
|
||||
|
||||
|
||||
def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
|
||||
"""Serialize a manifest to a YAML-friendly dict, preserving structural presence."""
|
||||
full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"})
|
||||
minimal = manifest.model_dump(
|
||||
mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True
|
||||
)
|
||||
|
||||
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
|
||||
if isinstance(full_val, dict):
|
||||
result = {}
|
||||
for k, fv in full_val.items():
|
||||
mv = min_val.get(k) if isinstance(min_val, dict) else None
|
||||
if k in _STRUCTURAL_KEYS:
|
||||
merged = merge(fv, mv, k)
|
||||
if merged is not None:
|
||||
result[k] = merged
|
||||
elif mv is not None:
|
||||
result[k] = merge(fv, mv, k)
|
||||
elif isinstance(fv, dict):
|
||||
merged = merge(fv, None, k)
|
||||
if merged:
|
||||
result[k] = merged
|
||||
return result if result else ({} if key in _STRUCTURAL_KEYS else result)
|
||||
elif isinstance(full_val, list):
|
||||
if min_val is not None:
|
||||
return full_val
|
||||
return []
|
||||
else:
|
||||
if min_val is not None:
|
||||
return full_val
|
||||
return None
|
||||
|
||||
result = merge(full, minimal)
|
||||
return _clean_for_yaml(result)
|
||||
|
||||
|
||||
def save_config(config: CastleConfig) -> None:
|
||||
"""Save castle configuration to castle.yaml."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}}
|
||||
|
||||
for name, manifest in config.components.items():
|
||||
data["components"][name] = _manifest_to_yaml_dict(manifest)
|
||||
|
||||
config_path = config.root / "castle.yaml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Ensure castle directories exist."""
|
||||
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
|
||||
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(SECRETS_DIR, 0o700)
|
||||
28
core/src/castle_core/generators/__init__.py
Normal file
28
core/src/castle_core/generators/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Castle infrastructure generators."""
|
||||
|
||||
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile
|
||||
from castle_core.generators.systemd import (
|
||||
build_podman_command,
|
||||
cron_to_interval_sec,
|
||||
cron_to_oncalendar,
|
||||
generate_timer,
|
||||
generate_unit,
|
||||
get_schedule_trigger,
|
||||
manifest_to_exec_start,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_podman_command",
|
||||
"cron_to_interval_sec",
|
||||
"cron_to_oncalendar",
|
||||
"find_app_dist",
|
||||
"generate_caddyfile",
|
||||
"generate_timer",
|
||||
"generate_unit",
|
||||
"get_schedule_trigger",
|
||||
"manifest_to_exec_start",
|
||||
"timer_name",
|
||||
"unit_name",
|
||||
]
|
||||
55
core/src/castle_core/generators/caddyfile.py
Normal file
55
core/src/castle_core/generators/caddyfile.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Caddyfile generation from castle config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from castle_core.config import GENERATED_DIR, CastleConfig
|
||||
|
||||
|
||||
def find_app_dist(config: CastleConfig) -> str | None:
|
||||
"""Find the app dist/ directory if it exists."""
|
||||
dist = config.root / "app" / "dist"
|
||||
if dist.exists() and (dist / "index.html").exists():
|
||||
return str(dist)
|
||||
return None
|
||||
|
||||
|
||||
def generate_caddyfile(config: CastleConfig) -> str:
|
||||
"""Generate Caddyfile content from castle config."""
|
||||
lines = [f":{config.gateway.port} {{"]
|
||||
|
||||
# Reverse proxy for each component with proxy.caddy and expose.http
|
||||
for name, manifest in config.components.items():
|
||||
if not (manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable):
|
||||
continue
|
||||
if not (manifest.expose and manifest.expose.http):
|
||||
continue
|
||||
|
||||
caddy = manifest.proxy.caddy
|
||||
http = manifest.expose.http
|
||||
path_prefix = caddy.path_prefix or f"/{name}"
|
||||
port = http.internal.port
|
||||
host = http.internal.host or "localhost"
|
||||
|
||||
lines.append(f" handle_path {path_prefix}/* {{")
|
||||
lines.append(f" reverse_proxy {host}:{port}")
|
||||
lines.append(" }")
|
||||
lines.append("")
|
||||
|
||||
# App SPA at root (must come after more-specific handle_path rules)
|
||||
app_dist = find_app_dist(config)
|
||||
if app_dist:
|
||||
lines.append(" handle {")
|
||||
lines.append(f" root * {app_dist}")
|
||||
lines.append(" try_files {path} /index.html")
|
||||
lines.append(" file_server")
|
||||
lines.append(" }")
|
||||
else:
|
||||
# Fallback: serve from generated directory
|
||||
fallback = GENERATED_DIR / "app"
|
||||
lines.append(" handle / {")
|
||||
lines.append(f" root * {fallback}")
|
||||
lines.append(" file_server")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
233
core/src/castle_core/generators/systemd.py
Normal file
233
core/src/castle_core/generators/systemd.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Systemd unit and timer generation from castle manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import CastleConfig, resolve_env_vars
|
||||
from castle_core.manifest import ComponentManifest, RestartPolicy
|
||||
|
||||
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
UNIT_PREFIX = "castle-"
|
||||
|
||||
|
||||
def unit_name(service_name: str) -> str:
|
||||
"""Get the systemd unit name for a service."""
|
||||
return f"{UNIT_PREFIX}{service_name}.service"
|
||||
|
||||
|
||||
def timer_name(service_name: str) -> str:
|
||||
"""Get the systemd timer name for a scheduled service."""
|
||||
return f"{UNIT_PREFIX}{service_name}.timer"
|
||||
|
||||
|
||||
def get_schedule_trigger(manifest: ComponentManifest) -> object | None:
|
||||
"""Return the schedule trigger if one exists, else None."""
|
||||
for t in manifest.triggers:
|
||||
if getattr(t, "type", None) == "schedule":
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def cron_to_oncalendar(cron: str) -> str:
|
||||
"""Best-effort conversion of cron expression to systemd OnCalendar.
|
||||
|
||||
Handles common patterns; falls back to using OnUnitActiveSec for the rest.
|
||||
"""
|
||||
parts = cron.strip().split()
|
||||
if len(parts) != 5:
|
||||
return ""
|
||||
|
||||
minute, hour, dom, month, dow = parts
|
||||
|
||||
# */N minutes → run every N minutes
|
||||
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
|
||||
return "" # Use OnUnitActiveSec instead
|
||||
|
||||
# Specific time daily: "0 2 * * *" → "*-*-* 02:00:00"
|
||||
if dom == "*" and month == "*" and dow == "*":
|
||||
h = hour.zfill(2) if hour != "*" else "*"
|
||||
m = minute.zfill(2) if minute != "*" else "*"
|
||||
return f"*-*-* {h}:{m}:00"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def cron_to_interval_sec(cron: str) -> int | None:
|
||||
"""Extract interval seconds from */N cron patterns."""
|
||||
parts = cron.strip().split()
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
minute, hour, dom, month, dow = parts
|
||||
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
|
||||
try:
|
||||
return int(minute[2:]) * 60
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def manifest_to_exec_start(manifest: ComponentManifest, root: Path) -> str:
|
||||
"""Convert a manifest's RunSpec to a systemd ExecStart command."""
|
||||
run = manifest.run
|
||||
if run is None:
|
||||
raise ValueError(f"Component '{manifest.id}' has no run spec")
|
||||
|
||||
match run.runner:
|
||||
case "python_uv_tool":
|
||||
uv_path = shutil.which("uv") or "uv"
|
||||
args_str = " ".join(run.args) if run.args else ""
|
||||
cmd = f"{uv_path} run {run.tool}"
|
||||
if args_str:
|
||||
cmd += f" {args_str}"
|
||||
return cmd
|
||||
case "python_module":
|
||||
python = run.python or shutil.which("python3") or "python3"
|
||||
args_str = " ".join(run.args) if run.args else ""
|
||||
cmd = f"{python} -m {run.module}"
|
||||
if args_str:
|
||||
cmd += f" {args_str}"
|
||||
return cmd
|
||||
case "command":
|
||||
argv = list(run.argv)
|
||||
resolved = shutil.which(argv[0])
|
||||
if resolved:
|
||||
argv[0] = resolved
|
||||
return " ".join(argv)
|
||||
case "container":
|
||||
return build_podman_command(manifest)
|
||||
case "node":
|
||||
pm = run.package_manager
|
||||
cmd = f"{pm} run {run.script}"
|
||||
if run.args:
|
||||
cmd += " " + " ".join(run.args)
|
||||
return cmd
|
||||
case _:
|
||||
raise ValueError(f"Unsupported runner '{run.runner}' for systemd unit")
|
||||
|
||||
|
||||
def build_podman_command(manifest: ComponentManifest) -> str:
|
||||
"""Build a podman/docker run command from a container RunSpec."""
|
||||
run = manifest.run
|
||||
podman = shutil.which("podman") or shutil.which("docker") or "podman"
|
||||
parts = [podman, "run", "--rm", f"--name=castle-{manifest.id}"]
|
||||
|
||||
for container_port, host_port in run.ports.items():
|
||||
parts.append(f"-p {host_port}:{container_port}")
|
||||
for vol in run.volumes:
|
||||
parts.append(f"-v {vol}")
|
||||
for key, val in run.env.items():
|
||||
parts.append(f"-e {key}={val}")
|
||||
if run.workdir:
|
||||
parts.append(f"-w {run.workdir}")
|
||||
|
||||
parts.append(run.image)
|
||||
if run.command:
|
||||
parts.extend(run.command)
|
||||
if run.args:
|
||||
parts.extend(run.args)
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def generate_unit(config: CastleConfig, name: str, manifest: ComponentManifest) -> str:
|
||||
"""Generate a systemd user unit file for a component."""
|
||||
run = manifest.run
|
||||
if run is None:
|
||||
raise ValueError(f"Component '{name}' has no run spec")
|
||||
|
||||
working_dir = config.root / (run.working_dir or name)
|
||||
exec_start = manifest_to_exec_start(manifest, config.root)
|
||||
|
||||
resolved_env = resolve_env_vars(run.env, manifest)
|
||||
env_lines = ""
|
||||
for key, value in resolved_env.items():
|
||||
env_lines += f"Environment={key}={value}\n"
|
||||
|
||||
# Add PATH so tools are findable
|
||||
env_lines += f'Environment="PATH={Path.home() / ".local/bin"}:/usr/local/bin:/usr/bin:/bin"\n'
|
||||
|
||||
sd = None
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
sd = manifest.manage.systemd
|
||||
|
||||
description = (sd and sd.description) or manifest.description or name
|
||||
after = " ".join(sd.after) if sd and sd.after else "network.target"
|
||||
wanted_by = " ".join(sd.wanted_by) if sd else "default.target"
|
||||
|
||||
is_scheduled = get_schedule_trigger(manifest) is not None
|
||||
|
||||
if is_scheduled:
|
||||
# Oneshot service for timer-driven jobs
|
||||
unit = f"""[Unit]
|
||||
Description=Castle: {description}
|
||||
After={after}
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory={working_dir}
|
||||
ExecStart={exec_start}
|
||||
{env_lines}"""
|
||||
else:
|
||||
restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value
|
||||
restart_sec = sd.restart_sec if sd else 5
|
||||
unit = f"""[Unit]
|
||||
Description=Castle: {description}
|
||||
After={after}
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={working_dir}
|
||||
ExecStart={exec_start}
|
||||
{env_lines}Restart={restart}
|
||||
RestartSec={restart_sec}
|
||||
SuccessExitStatus=143
|
||||
"""
|
||||
|
||||
if sd and sd.exec_reload:
|
||||
reload_argv = sd.exec_reload.split()
|
||||
resolved_reload = shutil.which(reload_argv[0])
|
||||
if resolved_reload:
|
||||
reload_argv[0] = resolved_reload
|
||||
unit += f"ExecReload={' '.join(reload_argv)}\n"
|
||||
|
||||
if sd and sd.no_new_privileges:
|
||||
unit += "NoNewPrivileges=true\n"
|
||||
|
||||
unit += f"""
|
||||
[Install]
|
||||
WantedBy={wanted_by}
|
||||
"""
|
||||
return unit
|
||||
|
||||
|
||||
def generate_timer(name: str, manifest: ComponentManifest) -> str | None:
|
||||
"""Generate a systemd timer unit if the component has a schedule trigger."""
|
||||
trigger = get_schedule_trigger(manifest)
|
||||
if trigger is None:
|
||||
return None
|
||||
|
||||
description = manifest.description or name
|
||||
|
||||
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
|
||||
on_calendar = cron_to_oncalendar(trigger.cron)
|
||||
interval_sec = cron_to_interval_sec(trigger.cron)
|
||||
|
||||
timer_lines = ""
|
||||
if on_calendar:
|
||||
timer_lines = f"OnCalendar={on_calendar}\n"
|
||||
elif interval_sec:
|
||||
timer_lines = f"OnBootSec=60\nOnUnitActiveSec={interval_sec}s\n"
|
||||
else:
|
||||
timer_lines = "OnBootSec=60\nOnUnitActiveSec=300\n"
|
||||
|
||||
return f"""[Unit]
|
||||
Description=Castle timer: {description}
|
||||
|
||||
[Timer]
|
||||
{timer_lines}Persistent=false
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
"""
|
||||
325
core/src/castle_core/manifest.py
Normal file
325
core/src/castle_core/manifest.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Castle component manifest — Pydantic models for the component registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
||||
|
||||
EnvMap = dict[str, str]
|
||||
|
||||
|
||||
class RestartPolicy(str, Enum):
|
||||
NO = "no"
|
||||
ON_FAILURE = "on-failure"
|
||||
ALWAYS = "always"
|
||||
|
||||
|
||||
class TLSMode(str, Enum):
|
||||
OFF = "off"
|
||||
INTERNAL = "internal"
|
||||
LETSENCRYPT = "letsencrypt"
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
TOOL = "tool"
|
||||
SERVICE = "service"
|
||||
WORKER = "worker"
|
||||
FRONTEND = "frontend"
|
||||
JOB = "job"
|
||||
REMOTE = "remote"
|
||||
CONTAINERIZED = "containerized"
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Run specs (discriminated union)
|
||||
# ---------------------
|
||||
|
||||
|
||||
class RunBase(BaseModel):
|
||||
runner: str
|
||||
working_dir: str | None = None
|
||||
env: EnvMap = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RunCommand(RunBase):
|
||||
runner: Literal["command"]
|
||||
argv: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class RunPythonModule(RunBase):
|
||||
runner: Literal["python_module"]
|
||||
module: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
python: str | None = None
|
||||
|
||||
|
||||
class RunPythonUvTool(RunBase):
|
||||
runner: Literal["python_uv_tool"]
|
||||
tool: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunContainer(RunBase):
|
||||
runner: Literal["container"]
|
||||
image: str
|
||||
command: list[str] | None = None
|
||||
args: list[str] = Field(default_factory=list)
|
||||
ports: dict[int, int] = Field(default_factory=dict)
|
||||
volumes: list[str] = Field(default_factory=list)
|
||||
workdir: str | None = None
|
||||
|
||||
|
||||
class RunNode(RunBase):
|
||||
runner: Literal["node"]
|
||||
script: str
|
||||
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunRemote(RunBase):
|
||||
runner: Literal["remote"]
|
||||
base_url: str
|
||||
health_url: str | None = None
|
||||
|
||||
|
||||
RunSpec = Annotated[
|
||||
Union[RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote],
|
||||
Field(discriminator="runner"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Triggers
|
||||
# ---------------------
|
||||
|
||||
|
||||
class TriggerManual(BaseModel):
|
||||
type: Literal["manual"] = "manual"
|
||||
|
||||
|
||||
class TriggerSchedule(BaseModel):
|
||||
type: Literal["schedule"] = "schedule"
|
||||
cron: str
|
||||
timezone: str = "America/Los_Angeles"
|
||||
|
||||
|
||||
class TriggerEvent(BaseModel):
|
||||
type: Literal["event"] = "event"
|
||||
source: str
|
||||
topic: str | None = None
|
||||
queue: str | None = None
|
||||
|
||||
|
||||
class TriggerRequest(BaseModel):
|
||||
type: Literal["request"] = "request"
|
||||
protocol: Literal["http", "https", "grpc"] = "http"
|
||||
|
||||
|
||||
TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Systemd management
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ReadinessHttpGet(BaseModel):
|
||||
http_get: str
|
||||
timeout_seconds: int = 2
|
||||
interval_seconds: int = 2
|
||||
success_codes: list[int] = Field(default_factory=lambda: [200])
|
||||
|
||||
|
||||
class SystemdSpec(BaseModel):
|
||||
enable: bool = True
|
||||
user: bool = True
|
||||
description: str | None = None
|
||||
after: list[str] = Field(default_factory=list)
|
||||
requires: list[str] = Field(default_factory=list)
|
||||
wanted_by: list[str] = Field(default_factory=lambda: ["default.target"])
|
||||
restart: RestartPolicy = RestartPolicy.ON_FAILURE
|
||||
restart_sec: int = 2
|
||||
no_new_privileges: bool = True
|
||||
readiness: ReadinessHttpGet | None = None
|
||||
exec_reload: str | None = None
|
||||
|
||||
|
||||
class ManageSpec(BaseModel):
|
||||
systemd: SystemdSpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Install (PATH shims)
|
||||
# ---------------------
|
||||
|
||||
|
||||
class PathInstallSpec(BaseModel):
|
||||
enable: bool = True
|
||||
alias: str | None = None
|
||||
shim: bool = True
|
||||
|
||||
|
||||
class InstallSpec(BaseModel):
|
||||
path: PathInstallSpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Tool spec
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
version: str = "1.0.0"
|
||||
source: str | None = None
|
||||
system_dependencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# HTTP exposure + proxy
|
||||
# ---------------------
|
||||
|
||||
|
||||
class HttpInternal(BaseModel):
|
||||
host: str = "127.0.0.1"
|
||||
port: int = Field(ge=1, le=65535)
|
||||
unix_socket: str | None = None
|
||||
|
||||
|
||||
class HttpPublic(BaseModel):
|
||||
hostnames: list[str] = Field(min_length=1)
|
||||
path_prefix: str = "/"
|
||||
tls: TLSMode = TLSMode.INTERNAL
|
||||
|
||||
|
||||
class HttpExposeSpec(BaseModel):
|
||||
internal: HttpInternal
|
||||
public: HttpPublic | None = None
|
||||
health_path: str | None = None
|
||||
|
||||
|
||||
class ExposeSpec(BaseModel):
|
||||
http: HttpExposeSpec | None = None
|
||||
|
||||
|
||||
class CaddySpec(BaseModel):
|
||||
enable: bool = True
|
||||
path_prefix: str | None = None
|
||||
extra_snippets: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProxySpec(BaseModel):
|
||||
caddy: CaddySpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Build spec
|
||||
# ---------------------
|
||||
|
||||
|
||||
class BuildSpec(BaseModel):
|
||||
working_dir: str | None = None
|
||||
commands: list[list[str]] = Field(default_factory=list)
|
||||
outputs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Capabilities
|
||||
# ---------------------
|
||||
|
||||
|
||||
class Capability(BaseModel):
|
||||
type: str
|
||||
name: str | None = None
|
||||
meta: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Component manifest
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ComponentManifest(BaseModel):
|
||||
id: str = ""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
run: RunSpec | None = None
|
||||
|
||||
triggers: list[TriggerSpec] = Field(default_factory=list)
|
||||
|
||||
manage: ManageSpec | None = None
|
||||
install: InstallSpec | None = None
|
||||
tool: ToolSpec | None = None
|
||||
expose: ExposeSpec | None = None
|
||||
proxy: ProxySpec | None = None
|
||||
build: BuildSpec | None = None
|
||||
|
||||
provides: list[Capability] = Field(default_factory=list)
|
||||
consumes: list[Capability] = Field(default_factory=list)
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
roles: set[Role] = set()
|
||||
|
||||
if self.run:
|
||||
if self.run.runner == "remote":
|
||||
roles.add(Role.REMOTE)
|
||||
if self.run.runner == "container":
|
||||
roles.add(Role.CONTAINERIZED)
|
||||
|
||||
if self.install and self.install.path and self.install.path.enable:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.tool:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.expose and self.expose.http:
|
||||
roles.add(Role.SERVICE)
|
||||
|
||||
if (
|
||||
self.manage
|
||||
and self.manage.systemd
|
||||
and self.manage.systemd.enable
|
||||
and not (self.expose and self.expose.http)
|
||||
):
|
||||
roles.add(Role.WORKER)
|
||||
|
||||
if self.build and (self.build.outputs or self.build.commands):
|
||||
roles.add(Role.FRONTEND)
|
||||
|
||||
if any(getattr(t, "type", None) == "schedule" for t in self.triggers):
|
||||
roles.add(Role.JOB)
|
||||
|
||||
if not roles:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
return sorted(roles, key=lambda r: r.value)
|
||||
|
||||
@property
|
||||
def source_dir(self) -> str | None:
|
||||
"""Best-effort relative directory for this component's source.
|
||||
|
||||
Resolution order: run.working_dir → build.working_dir → tool.source (strip trailing /).
|
||||
Returns None if no directory can be determined.
|
||||
"""
|
||||
if self.run and self.run.working_dir:
|
||||
return self.run.working_dir
|
||||
if self.build and self.build.working_dir:
|
||||
return self.build.working_dir
|
||||
if self.tool and self.tool.source:
|
||||
return self.tool.source.rstrip("/")
|
||||
return None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _basic_consistency(self) -> ComponentManifest:
|
||||
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||
if self.run and self.run.runner == "remote":
|
||||
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||
return self
|
||||
0
core/tests/__init__.py
Normal file
0
core/tests/__init__.py
Normal file
68
core/tests/conftest.py
Normal file
68
core/tests/conftest.py
Normal 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
|
||||
60
core/tests/test_caddyfile.py
Normal file
60
core/tests/test_caddyfile.py
Normal 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
171
core/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_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
189
core/tests/test_manifest.py
Normal 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"
|
||||
57
core/tests/test_systemd.py
Normal file
57
core/tests/test_systemd.py
Normal 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
|
||||
Reference in New Issue
Block a user