f33a563003
- Add ToolManifest Pydantic models with validators for ports, mounts, health checks, and traefik config - Implement in-memory ToolRegistry with YAML loading and built-in manifest scanning - Add FastAPI CRUD routes for listing, retrieving, and creating tool manifests - Include built-in manifests for runfusion and code-server - Harden web Dockerfile with unprivileged nginx and port 8080 - Add tool manifest specification documentation and architecture updates Fusion-Task-Id: FN-003
110 lines
3.4 KiB
Python
110 lines
3.4 KiB
Python
"""Pydantic v2 models for the Headquarter tool manifest schema."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
|
|
class PortConfig(BaseModel):
|
|
container_port: int = Field(..., ge=1, le=65535)
|
|
protocol: Literal["tcp", "udp"] = "tcp"
|
|
name: str | None = None
|
|
primary: bool = False
|
|
|
|
|
|
class MountConfig(BaseModel):
|
|
type: Literal["volume", "bind"] = "volume"
|
|
source_pattern: str
|
|
target: str
|
|
read_only: bool = False
|
|
|
|
@field_validator("target")
|
|
@classmethod
|
|
def _target_must_be_absolute(cls, v: str) -> str:
|
|
if not v.startswith("/"):
|
|
raise ValueError("mount target must be an absolute path")
|
|
return v
|
|
|
|
|
|
class SecretRef(BaseModel):
|
|
name: str
|
|
env_var: str
|
|
required: bool = True
|
|
|
|
|
|
class HealthCheckConfig(BaseModel):
|
|
type: Literal["http", "tcp", "command"] = "http"
|
|
path: str | None = None
|
|
command: list[str] | None = None
|
|
port: int | None = None
|
|
interval_seconds: int = Field(default=10, ge=1)
|
|
timeout_seconds: int = Field(default=5, ge=1)
|
|
retries: int = Field(default=3, ge=1)
|
|
start_period_seconds: int = Field(default=5, ge=0)
|
|
|
|
@model_validator(mode="after")
|
|
def _check_required_fields(self) -> HealthCheckConfig:
|
|
if self.type == "http" and not self.path:
|
|
raise ValueError('path is required when health_check.type is "http"')
|
|
if self.type == "command" and not self.command:
|
|
raise ValueError('command is required when health_check.type is "command"')
|
|
return self
|
|
|
|
|
|
class ResourceLimits(BaseModel):
|
|
cpus: float | None = Field(None, ge=0.01)
|
|
memory_mb: int | None = Field(None, ge=16)
|
|
memory_swap_mb: int | None = Field(None, ge=-1)
|
|
|
|
|
|
class ExecutableConfig(BaseModel):
|
|
node_version: str | None = None
|
|
npm_version: str | None = None
|
|
package_manager: Literal["npm", "pnpm", "yarn", "bun"] = "npm"
|
|
bootstrap_commands: list[str] = []
|
|
install_commands: list[str] = []
|
|
|
|
|
|
class TraefikConfig(BaseModel):
|
|
enabled: bool = True
|
|
subdomain_prefix: str | None = None
|
|
port: int | None = None
|
|
middlewares: list[str] = []
|
|
strip_prefix: bool = False
|
|
entrypoint: str | None = None
|
|
cert_resolver: str | None = None
|
|
|
|
|
|
class ToolManifest(BaseModel):
|
|
id: str = Field(..., pattern=r"^[a-z0-9\-]+$")
|
|
name: str
|
|
description: str = ""
|
|
version: str = "1.0.0"
|
|
image: str
|
|
runtime_command: list[str] | None = None
|
|
runtime_entrypoint: list[str] | None = None
|
|
runtime_user: str | None = None
|
|
runtime_working_dir: str | None = None
|
|
ports: list[PortConfig] = []
|
|
workspace_mounts: list[MountConfig] = []
|
|
config_mounts: list[MountConfig] = []
|
|
env: dict[str, str] = {}
|
|
secrets: list[SecretRef] = []
|
|
health_check: HealthCheckConfig | None = None
|
|
resource_limits: ResourceLimits | None = None
|
|
executable: ExecutableConfig | None = None
|
|
traefik: TraefikConfig | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _check_traefik_primary_port(self) -> ToolManifest:
|
|
traefik = self.traefik
|
|
if traefik is not None and traefik.enabled:
|
|
has_primary = any(port.primary for port in self.ports)
|
|
if not has_primary:
|
|
raise ValueError(
|
|
"at least one port must have primary=True when traefik.enabled is True"
|
|
)
|
|
return self
|