Canonical terms (see docs/registry.md glossary): - program: any catalog entry (tool/daemon/frontend) — the software - service / job: how a program is deployed (systemd .service / .timer) - deployment: umbrella for the unified service+job+program view Changes: - core: ServiceSpec/JobSpec component: → program: (component accepted as back-compat validation_alias); DeployedComponent → Deployment. - api: ComponentSummary/Detail → DeploymentSummary/Detail; GET /components → /deployments; GatewayRoute.component → program; GatewayInfo .component_count → deployment_count; ServiceActionResponse/action keys component → program. - app: matching type renames, /components → /deployments hook, route /component/:name → /deployment/:name, GatewayRoute.program. - docs: component-registry.md → registry.md (+ canonical glossary); CLAUDE.md endpoint list refreshed (drop removed /tools, add typed program/service/job + config routes). Tests: core 92, cli 24, api 52 green; app build clean; ruff clean.
352 lines
9.1 KiB
Python
352 lines
9.1 KiB
Python
"""Castle manifest models — program specs, service specs, job specs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Annotated, Literal, Union
|
|
|
|
from pydantic import AliasChoices, BaseModel, ConfigDict, 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"
|
|
|
|
|
|
# ---------------------
|
|
# Run specs (discriminated union)
|
|
# ---------------------
|
|
|
|
|
|
class RunBase(BaseModel):
|
|
runner: str
|
|
|
|
|
|
class RunCommand(RunBase):
|
|
runner: Literal["command"]
|
|
argv: list[str] = Field(min_length=1)
|
|
|
|
|
|
class RunPython(RunBase):
|
|
runner: Literal["python"]
|
|
program: 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, RunPython, RunContainer, RunNode, RunRemote],
|
|
Field(discriminator="runner"),
|
|
]
|
|
|
|
|
|
# ---------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------
|
|
# Commands spec — per-program dev verb overrides
|
|
# ---------------------
|
|
|
|
|
|
class CommandsSpec(BaseModel):
|
|
"""Per-program dev verb commands. Each verb is a list of argv lists run in
|
|
sequence. A declared verb overrides the stack default; an absent verb falls
|
|
back to the program's stack handler (if any), else the verb is unavailable.
|
|
|
|
This generalizes BuildSpec.commands to the rest of the verb contract, which
|
|
is what lets a wired-in repo with no `stack` declare how it is linted/tested/run.
|
|
"""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
lint: list[list[str]] | None = None
|
|
test: list[list[str]] | None = None
|
|
type_check: list[list[str]] | None = Field(default=None, alias="type-check")
|
|
check: list[list[str]] | None = None
|
|
run: list[list[str]] | None = None
|
|
install: list[list[str]] | None = None
|
|
uninstall: list[list[str]] | None = None
|
|
|
|
def for_verb(self, verb: str) -> list[list[str]] | None:
|
|
"""Return the declared commands for a verb name (accepts 'type-check')."""
|
|
return getattr(self, verb.replace("-", "_"), None)
|
|
|
|
|
|
# ---------------------
|
|
# 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)
|
|
|
|
|
|
# ---------------------
|
|
# Program spec — software identity
|
|
# ---------------------
|
|
|
|
|
|
class ProgramSpec(BaseModel):
|
|
"""Software catalog entry — what exists."""
|
|
|
|
id: str = ""
|
|
description: str | None = None
|
|
behavior: str | None = None
|
|
|
|
source: str | None = None
|
|
stack: str | None = None
|
|
|
|
# Wiring in existing repos: clone from `repo` (git URL) at optional `ref`;
|
|
# `source` (when set) is the local working copy and takes precedence.
|
|
repo: str | None = None
|
|
ref: str | None = None
|
|
|
|
# Per-program dev verb overrides (declared verbs override the stack default).
|
|
commands: CommandsSpec | None = None
|
|
|
|
system_dependencies: list[str] = Field(default_factory=list)
|
|
install_extras: list[str] = Field(default_factory=list)
|
|
version: str | 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)
|
|
|
|
@property
|
|
def source_dir(self) -> str | None:
|
|
"""Relative directory for this component's source, or None."""
|
|
if self.source:
|
|
return self.source.rstrip("/")
|
|
return None
|
|
|
|
|
|
# ---------------------
|
|
# Service spec — long-running daemon
|
|
# ---------------------
|
|
|
|
|
|
class ServiceSpec(BaseModel):
|
|
"""Long-running daemon deployment config."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str = ""
|
|
# The program this service deploys. (`component` accepted as a legacy alias.)
|
|
program: str | None = Field(
|
|
default=None, validation_alias=AliasChoices("program", "component")
|
|
)
|
|
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 _validate_consistency(self) -> ServiceSpec:
|
|
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
|
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."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str = ""
|
|
# The program this job runs. (`component` accepted as a legacy alias.)
|
|
program: str | None = Field(
|
|
default=None, validation_alias=AliasChoices("program", "component")
|
|
)
|
|
description: str | None = None
|
|
|
|
run: RunSpec
|
|
schedule: str
|
|
timezone: str = "America/Los_Angeles"
|
|
|
|
manage: ManageSpec | None = None
|
|
defaults: DefaultsSpec | None = None
|
|
|
|
|
|
# ---------------------
|
|
# Unit spec — unified definition
|
|
# ---------------------
|
|
|
|
|
|
class UnitKind(str, Enum):
|
|
TOOL = "tool"
|
|
SERVICE = "service"
|
|
SITE = "site"
|
|
JOB = "job"
|
|
|
|
|
|
class UnitSpec(BaseModel):
|
|
"""Unified program+deployment definition.
|
|
|
|
Expands into ProgramSpec + optional ServiceSpec/JobSpec at config load time.
|
|
"""
|
|
|
|
id: str = ""
|
|
kind: UnitKind
|
|
description: str | None = None
|
|
|
|
# Program identity
|
|
source: str | None = None
|
|
stack: str | None = None
|
|
system_dependencies: list[str] = Field(default_factory=list)
|
|
install_extras: list[str] = Field(default_factory=list)
|
|
version: str | None = None
|
|
build: BuildSpec | None = None
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
# Service fields (kind=service)
|
|
port: int | None = Field(default=None, ge=1, le=65535)
|
|
path_prefix: str | None = None
|
|
health_path: str | None = None
|
|
|
|
# Job fields (kind=job)
|
|
schedule: str | None = None
|
|
timezone: str = "America/Los_Angeles"
|
|
argv: list[str] | None = None
|
|
|
|
# Simplified env (flat dict, replaces defaults.env nesting)
|
|
env: EnvMap = Field(default_factory=dict)
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_kind_fields(self) -> UnitSpec:
|
|
if self.kind == UnitKind.SERVICE and self.port is None:
|
|
raise ValueError("kind=service requires 'port'")
|
|
if self.kind == UnitKind.JOB and self.schedule is None:
|
|
raise ValueError("kind=job requires 'schedule'")
|
|
if self.kind == UnitKind.JOB and self.argv is None:
|
|
raise ValueError("kind=job requires 'argv'")
|
|
return self
|