from __future__ import annotations from enum import Enum from typing import Annotated, Dict, List, Literal, Optional, Set, Union from pydantic import ( BaseModel, Field, HttpUrl, computed_field, model_validator, ) # ------------------------- # Shared primitives # ------------------------- 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 cwd: Optional[str] = None env: EnvMap = Field(default_factory=dict) 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: Optional[str] = None # e.g. "/path/to/python" class RunPythonUvTool(RunBase): runner: Literal["python_uv_tool"] tool: str # the installed tool name args: List[str] = Field(default_factory=list) class RunContainer(RunBase): runner: Literal["container"] image: str command: Optional[List[str]] = None # overrides image CMD if provided args: List[str] = Field(default_factory=list) ports: Dict[int, int] = Field(default_factory=dict) # container_port -> host_port volumes: List[str] = Field(default_factory=list) # "host:container[:ro]" workdir: Optional[str] = None class RunNode(RunBase): runner: Literal["node"] script: str # e.g. "dev", "start", or a file path package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm" args: List[str] = Field(default_factory=list) class RunRemote(RunBase): runner: Literal["remote"] base_url: HttpUrl # Optional: metadata for how Castle should treat it health_url: Optional[HttpUrl] = 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" # Keep this simple + explicit. You can later add "cron"/"interval". cron: str # e.g. "0 * * * *" timezone: str = "America/Los_Angeles" class TriggerEvent(BaseModel): type: Literal["event"] = "event" source: str # e.g. "kafka", "redis", "webhook", "fs" topic: Optional[str] = None queue: Optional[str] = None class TriggerRequest(BaseModel): type: Literal["request"] = "request" protocol: Literal["http", "https", "grpc"] = "http" TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest] # ------------------------- # Systemd management intent # ------------------------- class ReadinessHttpGet(BaseModel): http_get: HttpUrl | str # allow templated strings like "http://127.0.0.1:${PORT}/healthz" 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: Optional[str] = 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 # Optional hardening knobs (you can expand later) no_new_privileges: bool = True readiness: Optional[ReadinessHttpGet] = None class ManageSpec(BaseModel): systemd: Optional[SystemdSpec] = None # ------------------------- # Install intent (PATH shims etc.) # ------------------------- class PathInstallSpec(BaseModel): enable: bool = True # If set, Castle creates a shim with this name (e.g. ~/.local/bin/) alias: Optional[str] = None # If true, shim forwards args to `castle run ...` shim: bool = True class InstallSpec(BaseModel): path: Optional[PathInstallSpec] = None # ------------------------- # HTTP exposure + proxy intent # ------------------------- class HttpInternal(BaseModel): host: str = "127.0.0.1" port: int = Field(ge=1, le=65535) unix_socket: Optional[str] = None # e.g. "/run/user/1000/notes.sock" @model_validator(mode="after") def _exactly_one_target(self): # allow either (host+port) or unix_socket if self.unix_socket and (self.host or self.port): # host/port always exist via defaults; treat unix_socket as alternative # if unix_socket is set, we ignore host/port semantically return self return self class HttpPublic(BaseModel): hostnames: List[str] = Field(min_length=1) path_prefix: str = "/" tls: TLSMode = TLSMode.INTERNAL class HttpExposeSpec(BaseModel): internal: HttpInternal public: Optional[HttpPublic] = None health_path: Optional[str] = None # e.g. "/healthz" class ExposeSpec(BaseModel): http: Optional[HttpExposeSpec] = None # Future: cli, grpc, etc. class CaddySpec(BaseModel): enable: bool = True # Optional: extra per-component knobs extra_snippets: List[str] = Field(default_factory=list) class ProxySpec(BaseModel): caddy: Optional[CaddySpec] = None # ------------------------- # Build spec (mostly for frontends, but generic) # ------------------------- class BuildSpec(BaseModel): # e.g. ["pnpm", "build"] or ["uv", "run", "python", "-m", "build"] commands: List[List[str]] = Field(default_factory=list) outputs: List[str] = Field(default_factory=list) # paths relative to cwd/repo # ------------------------- # Capabilities (optional, for dependency graph) # ------------------------- class Capability(BaseModel): type: str # e.g. "http.endpoint", "cli.command", "ui.bundle" name: Optional[str] = None meta: Dict[str, str] = Field(default_factory=dict) # ------------------------- # The manifest (source of truth) # ------------------------- class ComponentManifest(BaseModel): id: str = Field(pattern=r"^[a-z0-9][a-z0-9\-_.]{1,63}$") name: Optional[str] = None description: Optional[str] = None run: RunSpec triggers: List[TriggerSpec] = Field(default_factory=list) manage: Optional[ManageSpec] = None install: Optional[InstallSpec] = None expose: Optional[ExposeSpec] = None proxy: Optional[ProxySpec] = None build: Optional[BuildSpec] = None provides: List[Capability] = Field(default_factory=list) consumes: List[Capability] = Field(default_factory=list) tags: List[str] = Field(default_factory=list) # ---- Derived ontology ---- @computed_field @property def roles(self) -> List[Role]: """ Derive roles purely from declared blocks. No 'kind/profile' field required. """ roles: Set[Role] = set() # Runner-derived roles if self.run.runner == "remote": roles.add(Role.REMOTE) if self.run.runner == "container": roles.add(Role.CONTAINERIZED) # Interface / integration-derived roles if self.install and self.install.path and self.install.path.enable: roles.add(Role.TOOL) if self.expose and self.expose.http: roles.add(Role.SERVICE) # Worker vs Service heuristic: # If it's managed (systemd) but not exposed over HTTP, it's worker-ish. if ( self.manage and self.manage.systemd and self.manage.systemd.enable and not (self.expose and self.expose.http) ): roles.add(Role.WORKER) # Frontend heuristic: build outputs present (or node runner + build) if self.build and (self.build.outputs or self.build.commands): # Avoid labeling everything with build as frontend if it's clearly not; # if it's exposing HTTP and has build, that's typically frontend or full-stack, # but frontend label is still useful. roles.add(Role.FRONTEND) # Job heuristic: any schedule trigger if any(getattr(t, "type", None) == "schedule" for t in self.triggers): roles.add(Role.JOB) # Fallback: if nothing else, treat as worker-ish if it’s supervised, # otherwise tool-ish if it’s transient. But keep it conservative. if not roles: roles.add(Role.TOOL) return sorted(roles, key=lambda r: r.value) # ---- Optional consistency checks ---- @model_validator(mode="after") def _basic_consistency(self): # If you declare proxy.caddy, you probably mean to expose HTTP publicly. if self.proxy and self.proxy.caddy and self.proxy.caddy.enable: if not (self.expose and self.expose.http and self.expose.http.public): # keep this as a soft constraint by raising a ValueError; # remove if you prefer permissive manifests raise ValueError( "proxy.caddy is enabled but expose.http.public is not set. " "Either disable caddy or declare public hostnames." ) # If systemd is enabled, ensure runner isn't something obviously incompatible. 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