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

@@ -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