337 lines
7.8 KiB
Python
337 lines
7.8 KiB
Python
"""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
|
|
|
|
|
|
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)
|
|
env: EnvMap = Field(default_factory=dict)
|
|
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):
|
|
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)
|
|
|
|
|
|
# ---------------------
|
|
# Defaults
|
|
# ---------------------
|
|
|
|
|
|
class DefaultsSpec(BaseModel):
|
|
env: EnvMap = Field(default_factory=dict)
|
|
|
|
|
|
# ---------------------
|
|
# Component manifest
|
|
# ---------------------
|
|
|
|
|
|
class ComponentManifest(BaseModel):
|
|
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.
|
|
"""
|
|
if self.source:
|
|
return self.source.rstrip("/")
|
|
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
|