refactor: Decouple roles.

This commit is contained in:
2026-02-23 01:49:24 -08:00
parent 72d35f2641
commit eeaa5045d0
55 changed files with 2144 additions and 1276 deletions

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import yaml
from castle_core.manifest import ComponentManifest, Role
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
def find_castle_root() -> Path:
@@ -47,36 +47,30 @@ class CastleConfig:
root: Path
gateway: GatewayConfig
components: dict[str, ComponentManifest]
components: dict[str, ComponentSpec]
services: dict[str, ServiceSpec]
jobs: dict[str, JobSpec]
@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."""
def tools(self) -> dict[str, ComponentSpec]:
"""Return components that are tools (have install.path or tool spec)."""
return {
k: v
for k, v in self.components.items()
if v.manage and v.manage.systemd and v.manage.systemd.enable
if (v.install and v.install.path) or v.tool
}
@property
def frontends(self) -> dict[str, ComponentSpec]:
"""Return components that are frontends (have build outputs)."""
return {
k: v
for k, v in self.components.items()
if v.build and (v.build.outputs or v.build.commands)
}
def resolve_env_vars(
env: dict[str, str], manifest: ComponentManifest
) -> dict[str, str]:
def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
"""Resolve ${secret:NAME} references in env values."""
resolved = {}
for key, value in env.items():
@@ -100,11 +94,25 @@ def _read_secret(name: str) -> str:
return f"<MISSING_SECRET:{name}>"
def _parse_component(name: str, data: dict) -> ComponentManifest:
"""Parse a components: entry directly into a ComponentManifest."""
def _parse_component(name: str, data: dict) -> ComponentSpec:
"""Parse a components: entry into a ComponentSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ComponentManifest.model_validate(data_copy)
return ComponentSpec.model_validate(data_copy)
def _parse_service(name: str, data: dict) -> ServiceSpec:
"""Parse a services: entry into a ServiceSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ServiceSpec.model_validate(data_copy)
def _parse_job(name: str, data: dict) -> JobSpec:
"""Parse a jobs: entry into a JobSpec."""
data_copy = dict(data)
data_copy["id"] = name
return JobSpec.model_validate(data_copy)
def load_config(root: Path | None = None) -> CastleConfig:
@@ -122,11 +130,25 @@ def load_config(root: Path | None = None) -> CastleConfig:
gateway_data = data.get("gateway", {})
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
components: dict[str, ComponentManifest] = {}
components: dict[str, ComponentSpec] = {}
for name, comp_data in data.get("components", {}).items():
components[name] = _parse_component(name, comp_data)
return CastleConfig(root=root, gateway=gateway, components=components)
services: dict[str, ServiceSpec] = {}
for name, svc_data in data.get("services", {}).items():
services[name] = _parse_service(name, svc_data)
jobs: dict[str, JobSpec] = {}
for name, job_data in data.get("jobs", {}).items():
jobs[name] = _parse_job(name, job_data)
return CastleConfig(
root=root,
gateway=gateway,
components=components,
services=services,
jobs=jobs,
)
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
@@ -165,11 +187,12 @@ _STRUCTURAL_KEYS = {
}
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 _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
exclude_fields = {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
minimal = spec.model_dump(
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
)
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
@@ -203,10 +226,22 @@ def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
def save_config(config: CastleConfig) -> None:
"""Save castle configuration to castle.yaml."""
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}}
data: dict = {"gateway": {"port": config.gateway.port}}
for name, manifest in config.components.items():
data["components"][name] = _manifest_to_yaml_dict(manifest)
if config.components:
data["components"] = {}
for name, spec in config.components.items():
data["components"][name] = _spec_to_yaml_dict(spec)
if config.services:
data["services"] = {}
for name, spec in config.services.items():
data["services"][name] = _spec_to_yaml_dict(spec)
if config.jobs:
data["jobs"] = {}
for name, spec in config.jobs.items():
data["jobs"][name] = _spec_to_yaml_dict(spec)
config_path = config.root / "castle.yaml"
with open(config_path, "w") as f:

View File

@@ -8,7 +8,6 @@ from castle_core.generators.systemd import (
cron_to_oncalendar,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
@@ -19,7 +18,6 @@ __all__ = [
"generate_caddyfile_from_registry",
"generate_timer",
"generate_unit_from_deployed",
"get_schedule_trigger",
"timer_name",
"unit_name",
]

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec
from castle_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import DeployedComponent
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
@@ -22,14 +22,6 @@ def timer_name(service_name: str) -> str:
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.
@@ -142,17 +134,17 @@ 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
def generate_timer(
name: str,
schedule: str,
description: str | None = None,
) -> str:
"""Generate a systemd timer unit from a cron schedule string."""
description = 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)
on_calendar = cron_to_oncalendar(schedule)
interval_sec = cron_to_interval_sec(schedule)
timer_lines = ""
if on_calendar:

View File

@@ -1,11 +1,11 @@
"""Castle component manifest — Pydantic models for the component registry."""
"""Castle manifest models — component specs, service specs, job specs."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
@@ -22,16 +22,6 @@ class TLSMode(str, Enum):
LETSENCRYPT = "letsencrypt"
class Role(str, Enum):
TOOL = "tool"
SERVICE = "service"
WORKER = "worker"
FRONTEND = "frontend"
JOB = "job"
REMOTE = "remote"
CONTAINERIZED = "containerized"
# ---------------------
# Run specs (discriminated union)
# ---------------------
@@ -82,36 +72,6 @@ RunSpec = Annotated[
]
# ---------------------
# 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
# ---------------------
@@ -166,7 +126,6 @@ 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)
@@ -238,90 +197,77 @@ class DefaultsSpec(BaseModel):
# ---------------------
# Component manifest
# Component spec — software identity
# ---------------------
class ComponentManifest(BaseModel):
class ComponentSpec(BaseModel):
"""Software catalog entry — what exists."""
id: str = ""
name: str | None = None
description: str | None = None
source: 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
defaults: DefaultsSpec | 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: source → tool.source (strip trailing /).
Returns None if no directory can be determined.
"""
"""Relative directory for this component's source, or None."""
if self.source:
return self.source.rstrip("/")
if self.tool and self.tool.source:
return self.tool.source.rstrip("/")
return None
# ---------------------
# Service spec — long-running daemon
# ---------------------
class ServiceSpec(BaseModel):
"""Long-running daemon deployment config."""
id: str = ""
component: str | None = None
description: str | None = None
run: RunSpec
expose: ExposeSpec | None = None
proxy: ProxySpec | None = None
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None
@model_validator(mode="after")
def _basic_consistency(self) -> ComponentManifest:
def _validate_consistency(self) -> ServiceSpec:
if self.manage and self.manage.systemd and self.manage.systemd.enable:
if self.run and self.run.runner == "remote":
if self.run.runner == "remote":
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
return self
# ---------------------
# Job spec — scheduled task
# ---------------------
class JobSpec(BaseModel):
"""Scheduled task that runs periodically and exits."""
id: str = ""
component: str | None = None
description: str | None = None
run: RunSpec
schedule: str
timezone: str = "America/Los_Angeles"
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None

View File

@@ -35,7 +35,7 @@ class DeployedComponent:
run_cmd: list[str]
env: dict[str, str] = field(default_factory=dict)
description: str | None = None
roles: list[str] = field(default_factory=list)
category: str = "service"
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
@@ -82,7 +82,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
roles=comp_data.get("roles", []),
category=comp_data.get("category", "service"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
@@ -120,8 +120,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["env"] = comp.env
if comp.description:
entry["description"] = comp.description
if comp.roles:
entry["roles"] = comp.roles
entry["category"] = comp.category
if comp.port is not None:
entry["port"] = comp.port
if comp.health_path: