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:
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
|
||||
# Deployment
|
||||
root_domain: str = "localhost"
|
||||
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
|
||||
|
||||
@@ -9,10 +9,13 @@ from sqlalchemy import text
|
||||
from app.config import settings
|
||||
from app.db import AsyncSessionLocal, engine
|
||||
from app.routers import routers
|
||||
from app.tools.registry import registry
|
||||
from app.tools.router import router as tools_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
registry.load_builtin_manifests()
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
@@ -41,6 +44,8 @@ app.add_middleware(
|
||||
for router in routers:
|
||||
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||
|
||||
app.include_router(tools_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
id: code-server
|
||||
name: code-server
|
||||
description: VS Code in the browser.
|
||||
version: "1.0.0"
|
||||
image: codercom/code-server:latest
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 8080
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/code-server"
|
||||
target: /home/coder/.config/code-server
|
||||
read_only: false
|
||||
env: {}
|
||||
secrets:
|
||||
- name: code-server-password
|
||||
env_var: PASSWORD
|
||||
required: false
|
||||
health_check:
|
||||
type: http
|
||||
path: /healthz
|
||||
port: 8080
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 5
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 4096
|
||||
memory_swap_mb: -1
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: code
|
||||
port: 8080
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
@@ -0,0 +1,46 @@
|
||||
id: runfusion
|
||||
name: RunFusion
|
||||
description: Executable Node.js environment for running and developing applications.
|
||||
version: "1.0.0"
|
||||
image: node:22-slim
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 8080
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/runfusion"
|
||||
target: /home/node/.config
|
||||
read_only: false
|
||||
env:
|
||||
NODE_ENV: development
|
||||
health_check:
|
||||
type: http
|
||||
path: /
|
||||
port: 8080
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 10
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 2048
|
||||
memory_swap_mb: -1
|
||||
executable:
|
||||
node_version: "22"
|
||||
package_manager: npm
|
||||
bootstrap_commands: []
|
||||
install_commands: []
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: runfusion
|
||||
port: 8080
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
"""In-memory tool manifest registry with YAML file loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.tools.models import ToolManifest
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""In-memory registry for tool manifests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._manifests: dict[str, ToolManifest] = {}
|
||||
|
||||
def load_builtin_manifests(self) -> None:
|
||||
"""Scan the built-in manifests directory and register all *.yml files."""
|
||||
manifests_dir = Path(__file__).parent / "manifests"
|
||||
if not manifests_dir.exists():
|
||||
return
|
||||
for file_path in sorted(manifests_dir.glob("*.yml")):
|
||||
self.load_file(file_path)
|
||||
|
||||
def load_file(self, path: Path) -> ToolManifest:
|
||||
"""Load a single YAML manifest file, validate it, and register it."""
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
manifest = ToolManifest.model_validate(data)
|
||||
self.register(manifest)
|
||||
return manifest
|
||||
|
||||
def register(self, manifest: ToolManifest) -> None:
|
||||
"""Store a manifest in the registry (idempotent upsert)."""
|
||||
self._manifests[manifest.id] = manifest
|
||||
|
||||
def get(self, tool_id: str) -> ToolManifest | None:
|
||||
"""Retrieve a manifest by tool id, or None if not found."""
|
||||
return self._manifests.get(tool_id)
|
||||
|
||||
def list(self) -> list[ToolManifest]:
|
||||
"""Return all registered manifests."""
|
||||
return list(self._manifests.values())
|
||||
|
||||
def remove(self, tool_id: str) -> ToolManifest | None:
|
||||
"""Remove a manifest by tool id and return it, or None if not found."""
|
||||
return self._manifests.pop(tool_id, None)
|
||||
|
||||
|
||||
# Module-level singleton — callers must explicitly bootstrap via
|
||||
# registry.load_builtin_manifests() (typically in a FastAPI lifespan).
|
||||
registry = ToolRegistry()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""FastAPI routes for the tool manifest registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.tools.models import ToolManifest
|
||||
from app.tools.registry import registry
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolManifest])
|
||||
def list_tools() -> list[ToolManifest]:
|
||||
return registry.list()
|
||||
|
||||
|
||||
@router.get("/{tool_id}", response_model=ToolManifest)
|
||||
def get_tool(tool_id: str) -> ToolManifest:
|
||||
manifest = registry.get(tool_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool '{tool_id}' not found",
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
@router.post("", response_model=ToolManifest, status_code=status.HTTP_201_CREATED)
|
||||
def create_tool(manifest: ToolManifest) -> ToolManifest:
|
||||
if registry.get(manifest.id) is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Tool '{manifest.id}' already exists",
|
||||
)
|
||||
registry.register(manifest)
|
||||
return manifest
|
||||
Reference in New Issue
Block a user