feat: Enhance gateway management and add Caddyfile generation support

This commit is contained in:
2026-02-21 01:08:11 -08:00
parent 54ba2ccc62
commit f99c2dad86
16 changed files with 711 additions and 230 deletions

342
working/schema.py Normal file
View File

@@ -0,0 +1,342 @@
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