Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.
- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
normalizes old runner shapes; CastleConfig.deployments with derived
services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.
Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
334 lines
10 KiB
Python
334 lines
10 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"
|
|
|
|
|
|
# ---------------------
|
|
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
|
# ---------------------
|
|
#
|
|
# A *launcher* is the process-launch mechanism for a systemd-managed deployment.
|
|
# It is orthogonal to the deployment's `manager`: only systemd deployments have a
|
|
# launcher, and it says only how the process starts — not how it's supervised.
|
|
# The non-process managers (caddy/path/none) have no launcher; their fields live
|
|
# on the deployment variant itself.
|
|
|
|
|
|
class LaunchBase(BaseModel):
|
|
launcher: str
|
|
|
|
|
|
class RunCommand(LaunchBase):
|
|
launcher: Literal["command"]
|
|
argv: list[str] = Field(min_length=1)
|
|
|
|
|
|
class RunPython(LaunchBase):
|
|
launcher: Literal["python"]
|
|
program: str
|
|
args: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RunContainer(LaunchBase):
|
|
launcher: 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(LaunchBase):
|
|
launcher: Literal["node"]
|
|
script: str
|
|
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
|
args: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RunCompose(LaunchBase):
|
|
"""A multi-container stack supervised as one unit via ``docker compose``.
|
|
|
|
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
|
|
networking, startup ordering, and per-service health — Castle delegates rather
|
|
than reinventing orchestration. The unit runs ``compose up`` attached
|
|
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
|
|
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
|
|
``defaults.env``); compose interpolates them from the process environment.
|
|
"""
|
|
|
|
launcher: Literal["compose"]
|
|
file: str = "docker-compose.yml" # resolved relative to the program source
|
|
project_name: str | None = None # ``-p``; defaults to ``castle-<name>``
|
|
|
|
|
|
LaunchSpec = Annotated[
|
|
Union[
|
|
RunCommand,
|
|
RunPython,
|
|
RunContainer,
|
|
RunNode,
|
|
RunCompose,
|
|
],
|
|
Field(discriminator="launcher"),
|
|
]
|
|
|
|
|
|
# ---------------------
|
|
# 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 HttpExposeSpec(BaseModel):
|
|
internal: HttpInternal
|
|
health_path: str | None = None
|
|
|
|
|
|
class ExposeSpec(BaseModel):
|
|
http: HttpExposeSpec | 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
|
|
# Derived at load time from how the program is deployed (its deployment's
|
|
# kind: service|job|tool|static|reference) — never stored. See kind_for.
|
|
kind: 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
|
|
|
|
|
|
# ---------------------
|
|
# Deployment specs — a program materialized into the runtime
|
|
# ---------------------
|
|
#
|
|
# A deployment is discriminated on its `manager` — who supervises/realizes it:
|
|
# systemd → a process (or, with a `schedule`, a `.timer`)
|
|
# caddy → a gateway route serving a static dir (file_server)
|
|
# path → a CLI installed on PATH (uv tool install)
|
|
# none → an external reference we only point at (remote)
|
|
# The human "kind" (service/job/tool/static/reference) is *derived* from the
|
|
# manager (+ schedule presence), never stored — see `kind_for`.
|
|
|
|
|
|
class DeploymentBase(BaseModel):
|
|
"""Fields common to every deployment, regardless of manager."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str = ""
|
|
# The program this deployment materializes. (`component` = legacy alias.)
|
|
program: str | None = Field(
|
|
default=None, validation_alias=AliasChoices("program", "component")
|
|
)
|
|
description: str | None = None
|
|
defaults: DefaultsSpec | None = None
|
|
|
|
|
|
class SystemdDeployment(DeploymentBase):
|
|
"""A process supervised by systemd — a *service*, or a *job* when scheduled."""
|
|
|
|
manager: Literal["systemd"]
|
|
run: LaunchSpec
|
|
# Present → a `.timer` (job); absent → a continuous `.service` (service).
|
|
schedule: str | None = None
|
|
timezone: str = "America/Los_Angeles"
|
|
expose: ExposeSpec | None = None
|
|
# Route <name>.<gateway.domain> to this process. False → host:port only.
|
|
proxy: bool = False
|
|
# Also publish to the public internet via the Cloudflare tunnel, at
|
|
# <name>.<gateway.public_domain>. Opt-in; requires `proxy`.
|
|
public: bool = False
|
|
manage: ManageSpec | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _validate(self) -> SystemdDeployment:
|
|
if self.public and not self.proxy:
|
|
raise ValueError("public requires proxy (an exposed process).")
|
|
return self
|
|
|
|
|
|
class CaddyDeployment(DeploymentBase):
|
|
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
|
|
|
The gateway *is* its runtime; it's inherently exposed at its subdomain. ``root``
|
|
is the built dir to serve, relative to the program source (e.g. ``dist``/``public``).
|
|
"""
|
|
|
|
manager: Literal["caddy"]
|
|
root: str = "dist"
|
|
# Inherently exposed at its subdomain; `public` = also project via the tunnel.
|
|
public: bool = False
|
|
|
|
|
|
class PathDeployment(DeploymentBase):
|
|
"""A CLI installed on PATH via ``uv tool install`` — no process, no route.
|
|
|
|
Lifecycle is install/uninstall (what start/stop/enable/disable map to).
|
|
"""
|
|
|
|
manager: Literal["path"]
|
|
|
|
|
|
class RemoteDeployment(DeploymentBase):
|
|
"""An external service (another node) — we only reference/route it, never run it."""
|
|
|
|
manager: Literal["none"]
|
|
base_url: str
|
|
health_url: str | None = None
|
|
|
|
|
|
DeploymentSpec = Annotated[
|
|
Union[SystemdDeployment, CaddyDeployment, PathDeployment, RemoteDeployment],
|
|
Field(discriminator="manager"),
|
|
]
|
|
|
|
|
|
def kind_for(spec: DeploymentSpec) -> str:
|
|
"""The derived kind of a deployment: service|job|tool|static|reference."""
|
|
if spec.manager == "systemd":
|
|
return "job" if getattr(spec, "schedule", None) else "service"
|
|
return {"caddy": "static", "path": "tool", "none": "reference"}[spec.manager]
|