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:
Fusion
2026-05-14 09:14:40 +02:00
parent 477abad4c9
commit f33a563003
25 changed files with 1038 additions and 24 deletions
+3
View File
@@ -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}"
+5
View File
@@ -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:
View File
@@ -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
+109
View File
@@ -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
+52
View File
@@ -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()
+37
View File
@@ -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