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

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

View File

@@ -6,71 +6,22 @@ import argparse
import subprocess
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile
GATEWAY_COMPONENT = "castle-gateway"
GATEWAY_UNIT = "castle-castle-gateway.service"
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)
def _write_generated_files(config: CastleConfig) -> None:
"""Write generated Caddyfile."""
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(_generate_caddyfile(config))
caddyfile_path.write_text(generate_caddyfile(config))
print(f" Generated {caddyfile_path}")
app_dist = _find_app_dist(config)
app_dist = find_app_dist(config)
if app_dist:
print(f" App: {app_dist}")
else:
@@ -104,7 +55,7 @@ def run_gateway(args: argparse.Namespace) -> int:
def _gateway_dry_run(config: CastleConfig) -> int:
"""Print generated Caddyfile without applying."""
print("# Caddyfile")
print(_generate_caddyfile(config))
print(generate_caddyfile(config))
return 0

View File

@@ -3,254 +3,34 @@
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_cli.config import (
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_cli.manifest import ComponentManifest, RestartPolicy
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit,
get_schedule_trigger,
timer_name,
unit_name,
)
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
"""
def _install_unit(unit_name: str, content: str) -> None:
def _install_unit(uname: str, content: str) -> None:
"""Write a systemd unit file."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
unit_path = SYSTEMD_USER_DIR / unit_name
unit_path = SYSTEMD_USER_DIR / uname
unit_path.write_text(content)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
def _remove_unit(unit_name: str) -> None:
def _remove_unit(uname: str) -> None:
"""Remove a systemd unit file."""
unit_path = SYSTEMD_USER_DIR / unit_name
unit_path = SYSTEMD_USER_DIR / uname
if unit_path.exists():
unit_path.unlink()
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
@@ -307,22 +87,22 @@ def _service_enable(config: CastleConfig, name: str) -> int:
ensure_dirs()
# Generate and install the service unit
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
_install_unit(svc_unit, svc_content)
# Check for timer
timer_content = _generate_timer(name, manifest)
timer_content = generate_timer(name, manifest)
if timer_content:
timer_unit = _timer_name(name)
_install_unit(timer_unit, timer_content)
tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content)
print(f"Enabling {name} (scheduled)...")
subprocess.run(["systemctl", "--user", "enable", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "start", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "enable", tmr_unit], check=False)
subprocess.run(["systemctl", "--user", "start", tmr_unit], check=False)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -354,17 +134,17 @@ def _service_enable(config: CastleConfig, name: str) -> int:
def _service_disable(name: str) -> int:
"""Stop and disable a service (and timer if present)."""
svc_unit = _unit_name(name)
timer_unit = _timer_name(name)
svc_unit = unit_name(name)
tmr_unit = timer_name(name)
print(f"Disabling {name}...")
# Stop and disable timer if exists
timer_path = SYSTEMD_USER_DIR / timer_unit
timer_path = SYSTEMD_USER_DIR / tmr_unit
if timer_path.exists():
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", timer_unit], check=False)
_remove_unit(timer_unit)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", tmr_unit], check=False)
_remove_unit(tmr_unit)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
subprocess.run(["systemctl", "--user", "disable", svc_unit], check=False)
@@ -380,12 +160,12 @@ def _service_status(config: CastleConfig) -> int:
print("=" * 50)
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", timer_unit],
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -398,9 +178,9 @@ def _service_status(config: CastleConfig) -> int:
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
else:
unit_name = _unit_name(name)
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", unit_name],
["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True,
)
status = result.stdout.strip()
@@ -433,14 +213,14 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
print(f"Error: '{name}' has no run spec defined")
return 1
svc_unit = _unit_name(name)
svc_content = _generate_unit(config, name, manifest)
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
print(f"# {svc_unit}")
print(svc_content)
timer_content = _generate_timer(name, manifest)
timer_content = generate_timer(name, manifest)
if timer_content:
print(f"# {_timer_name(name)}")
print(f"# {timer_name(name)}")
print(timer_content)
return 0
@@ -468,12 +248,12 @@ def _services_start(config: CastleConfig) -> int:
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and gateway."""
for name, manifest in config.managed.items():
is_scheduled = _get_schedule_trigger(manifest) is not None
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
timer_unit = _timer_name(name)
subprocess.run(["systemctl", "--user", "stop", timer_unit], check=False)
unit_name = _unit_name(name)
subprocess.run(["systemctl", "--user", "stop", unit_name], check=False)
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
return 0

View File

@@ -1,217 +1,15 @@
"""Castle configuration and registry management."""
"""Re-export from castle-core for backward compatibility."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
import yaml
from castle_cli.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)
from castle_core.config import * # noqa: F401, F403
from castle_core.config import ( # noqa: F401 — explicit re-exports for type checkers
CASTLE_HOME,
GENERATED_DIR,
SECRETS_DIR,
CastleConfig,
GatewayConfig,
ensure_dirs,
find_castle_root,
load_config,
resolve_env_vars,
save_config,
)

View File

@@ -1,325 +1,37 @@
"""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
"""Re-export from castle-core for backward compatibility."""
from castle_core.manifest import * # noqa: F401, F403
from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers
BuildSpec,
CaddySpec,
Capability,
ComponentManifest,
EnvMap,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
HttpPublic,
InstallSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
ReadinessHttpGet,
RestartPolicy,
Role,
RunBase,
RunCommand,
RunContainer,
RunNode,
RunPythonModule,
RunPythonUvTool,
RunRemote,
RunSpec,
SystemdSpec,
TLSMode,
ToolSpec,
TriggerEvent,
TriggerManual,
TriggerRequest,
TriggerSchedule,
TriggerSpec,
)