Files
wild-pc/working/schema.py

343 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>)
alias: Optional[str] = None
# If true, shim forwards args to `castle run <id> ...`
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 its supervised,
# otherwise tool-ish if its 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