feat(FN-003): add tool manifest registry with FastAPI CRUD and built-in manifests
- 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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user