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
+1
View File
@@ -6,6 +6,7 @@ TOOL_DOMAIN=tools.localhost
# API / Web URLs # API / Web URLs
API_URL=http://localhost:8000 API_URL=http://localhost:8000
WEB_URL=http://localhost:5173 WEB_URL=http://localhost:5173
CORS_ORIGINS=http://localhost:5173
# Database (local development) # Database (local development)
POSTGRES_USER=postgres POSTGRES_USER=postgres
+1
View File
@@ -0,0 +1 @@
ignore-build-scripts=false
+2 -1
View File
@@ -4,10 +4,11 @@ Hosted workspace and tool-orchestration platform where authenticated users creat
## Current Status ## Current Status
This repository is an initial scaffold (FN-002). It provides: This repository provides:
- React + Vite + TypeScript frontend (`apps/web`) - React + Vite + TypeScript frontend (`apps/web`)
- FastAPI + Python backend (`apps/api`) - FastAPI + Python backend (`apps/api`)
- Manifest-driven tool registry with built-in RunFusion and code-server definitions
- Root monorepo tooling (pnpm workspace, Makefile) - Root monorepo tooling (pnpm workspace, Makefile)
- Docker Compose local development stack - Docker Compose local development stack
- Deployment skeleton for Portainer + Traefik - Deployment skeleton for Portainer + Traefik
+4
View File
@@ -5,10 +5,14 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
COPY app/ ./app/ COPY app/ ./app/
COPY pyproject.toml ./ COPY pyproject.toml ./
RUN pip install --no-cache-dir -e "." RUN pip install --no-cache-dir -e "."
USER appuser
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+3
View File
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
# Database # Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter" database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
# CORS
cors_origins: str = "http://localhost:5173"
# Deployment # Deployment
root_domain: str = "localhost" root_domain: str = "localhost"
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}" 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.config import settings
from app.db import AsyncSessionLocal, engine from app.db import AsyncSessionLocal, engine
from app.routers import routers from app.routers import routers
from app.tools.registry import registry
from app.tools.router import router as tools_router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
registry.load_builtin_manifests()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
await session.execute(text("SELECT 1")) await session.execute(text("SELECT 1"))
@@ -41,6 +44,8 @@ app.add_middleware(
for router in routers: for router in routers:
app.include_router(router, prefix=settings.api_v1_prefix) app.include_router(router, prefix=settings.api_v1_prefix)
app.include_router(tools_router, prefix=settings.api_v1_prefix)
@app.get("/health") @app.get("/health")
async def health() -> JSONResponse: 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
+5
View File
@@ -13,6 +13,7 @@ dependencies = [
"pyjwt>=2.8.0", "pyjwt>=2.8.0",
"cryptography>=44.0.0", "cryptography>=44.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"pyyaml>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -46,6 +47,10 @@ strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
exclude = ["alembic/versions"] exclude = ["alembic/versions"]
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "yaml"
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+243
View File
@@ -0,0 +1,243 @@
"""Tests for the tool manifest Pydantic models."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.tools.models import (
ExecutableConfig,
HealthCheckConfig,
MountConfig,
PortConfig,
ResourceLimits,
SecretRef,
ToolManifest,
TraefikConfig,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _minimal_manifest(**overrides: object) -> ToolManifest:
defaults: dict[str, object] = {
"id": "test-tool",
"name": "Test Tool",
"image": "test:latest",
"ports": [PortConfig(container_port=8080, primary=True)],
"traefik": TraefikConfig(enabled=False),
}
defaults.update(overrides)
return ToolManifest.model_validate(defaults)
# ---------------------------------------------------------------------------
# Valid construction
# ---------------------------------------------------------------------------
def test_valid_runfusion_shape() -> None:
manifest = ToolManifest(
id="runfusion",
name="RunFusion",
description="Executable Node.js environment.",
image="node:22-slim",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/runfusion", target="/home/node/.config"
)
],
env={"NODE_ENV": "development"},
health_check=HealthCheckConfig(
type="http", path="/", port=8080, start_period_seconds=10
),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=2048),
executable=ExecutableConfig(node_version="22", package_manager="npm"),
traefik=TraefikConfig(
enabled=True, subdomain_prefix="runfusion", port=8080
),
)
assert manifest.id == "runfusion"
assert manifest.ports[0].primary is True
assert manifest.traefik is not None
assert manifest.traefik.enabled is True
def test_valid_code_server_shape() -> None:
manifest = ToolManifest(
id="code-server",
name="code-server",
description="VS Code in the browser.",
image="codercom/code-server:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/code-server",
target="/home/coder/.config/code-server",
)
],
health_check=HealthCheckConfig(type="http", path="/healthz", port=8080),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(enabled=True, subdomain_prefix="code", port=8080),
secrets=[
SecretRef(name="code-server-password", env_var="PASSWORD", required=False)
],
)
assert manifest.id == "code-server"
assert manifest.secrets[0].env_var == "PASSWORD"
# ---------------------------------------------------------------------------
# Invalid id values
# ---------------------------------------------------------------------------
def test_invalid_id_uppercase() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="RunFusion")
assert "id" in str(exc_info.value)
def test_invalid_id_spaces() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="run fusion")
assert "id" in str(exc_info.value)
def test_invalid_id_empty_string() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="")
assert "id" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Port validation
# ---------------------------------------------------------------------------
def test_invalid_container_port_zero() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=0)
assert "container_port" in str(exc_info.value)
def test_invalid_container_port_too_high() -> None:
with pytest.raises(ValidationError) as exc_info:
PortConfig(container_port=70000)
assert "container_port" in str(exc_info.value)
# ---------------------------------------------------------------------------
# Mount target validation
# ---------------------------------------------------------------------------
def test_mount_target_not_absolute() -> None:
with pytest.raises(ValidationError) as exc_info:
MountConfig(source_pattern="{project_repo}", target="workspace")
assert "absolute" in str(exc_info.value).lower()
# ---------------------------------------------------------------------------
# HealthCheck validation
# ---------------------------------------------------------------------------
def test_health_check_http_missing_path() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="http")
assert "path" in str(exc_info.value)
def test_health_check_command_missing_command() -> None:
with pytest.raises(ValidationError) as exc_info:
HealthCheckConfig(type="command")
assert "command" in str(exc_info.value)
def test_health_check_tcp_allows_missing_path() -> None:
hc = HealthCheckConfig(type="tcp")
assert hc.type == "tcp"
# ---------------------------------------------------------------------------
# Traefik + primary port validation
# ---------------------------------------------------------------------------
def test_missing_primary_port_when_traefik_enabled() -> None:
with pytest.raises(ValidationError) as exc_info:
ToolManifest(
id="bad-tool",
name="Bad Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=True),
)
assert "primary" in str(exc_info.value).lower()
def test_traefik_disabled_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
traefik=TraefikConfig(enabled=False),
)
assert manifest.traefik is not None
assert manifest.traefik.enabled is False
def test_no_traefik_allows_no_primary_port() -> None:
manifest = ToolManifest(
id="no-route",
name="No Route",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=False)],
)
assert manifest.traefik is None
# ---------------------------------------------------------------------------
# Resource limits validation
# ---------------------------------------------------------------------------
def test_resource_limits_cpus_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(cpus=0.001)
assert "cpus" in str(exc_info.value)
def test_resource_limits_memory_mb_too_low() -> None:
with pytest.raises(ValidationError) as exc_info:
ResourceLimits(memory_mb=8)
assert "memory_mb" in str(exc_info.value)
def test_resource_limits_memory_swap_negative_one_ok() -> None:
rl = ResourceLimits(memory_swap_mb=-1)
assert rl.memory_swap_mb == -1
# ---------------------------------------------------------------------------
# Serialization round-trip
# ---------------------------------------------------------------------------
def test_serialization_roundtrip() -> None:
original = _minimal_manifest(
id="roundtrip",
name="Roundtrip Tool",
ports=[PortConfig(container_port=3000, name="http", primary=True)],
traefik=TraefikConfig(enabled=True, subdomain_prefix="rt"),
)
dumped = original.model_dump(mode="json")
restored = ToolManifest.model_validate(dumped)
assert restored.id == original.id
assert restored.ports[0].container_port == original.ports[0].container_port
assert restored.traefik is not None
assert restored.traefik.subdomain_prefix == "rt"
+159
View File
@@ -0,0 +1,159 @@
"""Tests for the in-memory tool manifest registry."""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.tools.models import PortConfig, ToolManifest, TraefikConfig
from app.tools.registry import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def registry() -> ToolRegistry:
return ToolRegistry()
@pytest.fixture
def sample_manifest() -> ToolManifest:
return ToolManifest(
id="test-tool",
name="Test Tool",
image="test:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
# ---------------------------------------------------------------------------
# Register / get round-trip
# ---------------------------------------------------------------------------
def test_register_and_get(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.id == "test-tool"
def test_get_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.get("missing") is None
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_returns_all(registry: ToolRegistry) -> None:
m1 = ToolManifest(
id="tool-a",
name="Tool A",
image="a:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
m2 = ToolManifest(
id="tool-b",
name="Tool B",
image="b:latest",
ports=[PortConfig(container_port=3000, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(m1)
registry.register(m2)
assert len(registry.list()) == 2
ids = {m.id for m in registry.list()}
assert ids == {"tool-a", "tool-b"}
# ---------------------------------------------------------------------------
# Overwrite behavior
# ---------------------------------------------------------------------------
def test_register_overwrites_existing(
registry: ToolRegistry,
sample_manifest: ToolManifest,
) -> None:
registry.register(sample_manifest)
updated = ToolManifest(
id="test-tool",
name="Updated Tool",
image="updated:latest",
ports=[PortConfig(container_port=8080, primary=True)],
traefik=TraefikConfig(enabled=False),
)
registry.register(updated)
retrieved = registry.get("test-tool")
assert retrieved is not None
assert retrieved.name == "Updated Tool"
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
def test_remove_returns_manifest(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
registry.register(sample_manifest)
removed = registry.remove("test-tool")
assert removed is not None
assert removed.id == "test-tool"
assert registry.get("test-tool") is None
def test_remove_missing_returns_none(registry: ToolRegistry) -> None:
assert registry.remove("missing") is None
# ---------------------------------------------------------------------------
# Load file
# ---------------------------------------------------------------------------
def test_load_valid_yaml_file(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "my-tool.yml"
yaml_path.write_text(
"""
id: my-tool
name: My Tool
image: my-tool:latest
ports:
- container_port: 8080
primary: true
traefik:
enabled: false
""",
encoding="utf-8",
)
manifest = registry.load_file(yaml_path)
assert manifest.id == "my-tool"
assert manifest.name == "My Tool"
assert manifest.ports[0].container_port == 8080
def test_load_invalid_yaml_raises(registry: ToolRegistry, tmp_path: Path) -> None:
yaml_path = tmp_path / "bad-tool.yml"
yaml_path.write_text(
"""
id: BAD ID
name: Bad Tool
image: bad:latest
""",
encoding="utf-8",
)
with pytest.raises(ValidationError):
registry.load_file(yaml_path)
# ---------------------------------------------------------------------------
# Load builtin manifests
# ---------------------------------------------------------------------------
def test_load_builtin_manifests(registry: ToolRegistry) -> None:
registry.load_builtin_manifests()
# Built-in manifests from Step 4 may not exist yet in isolation,
# but the method should not raise regardless of directory contents.
assert isinstance(registry.list(), list)
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the FastAPI tool manifest router."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.tools.registry import registry
client = TestClient(app)
@pytest.fixture(autouse=True)
def _reset_registry() -> None:
registry._manifests.clear()
registry.load_builtin_manifests()
# ---------------------------------------------------------------------------
# GET /api/v1/tools
# ---------------------------------------------------------------------------
def test_list_tools_includes_builtins() -> None:
response = client.get("/api/v1/tools")
assert response.status_code == 200
data = response.json()
ids = {item["id"] for item in data}
assert "runfusion" in ids
assert "code-server" in ids
# ---------------------------------------------------------------------------
# GET /api/v1/tools/{tool_id}
# ---------------------------------------------------------------------------
def test_get_tool_runfusion() -> None:
response = client.get("/api/v1/tools/runfusion")
assert response.status_code == 200
data = response.json()
assert data["id"] == "runfusion"
assert data["name"] == "RunFusion"
def test_get_tool_not_found() -> None:
response = client.get("/api/v1/tools/nonexistent")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/v1/tools
# ---------------------------------------------------------------------------
def test_create_tool_success() -> None:
payload = {
"id": "new-tool",
"name": "New Tool",
"image": "new-tool:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 201
data = response.json()
assert data["id"] == "new-tool"
assert data["name"] == "New Tool"
def test_create_tool_duplicate() -> None:
payload = {
"id": "runfusion",
"name": "Duplicate",
"image": "dup:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 409
def test_create_tool_invalid_id() -> None:
payload = {
"id": "Bad ID",
"name": "Bad Tool",
"image": "bad:latest",
"ports": [{"container_port": 3000, "primary": True}],
"traefik": {"enabled": False},
}
response = client.post("/api/v1/tools", json=payload)
assert response.status_code == 422
+1
View File
@@ -0,0 +1 @@
ignore-build-scripts=false
+2 -2
View File
@@ -9,9 +9,9 @@ RUN npm install -g pnpm@11.1.1 \
COPY . . COPY . .
RUN pnpm build RUN pnpm build
FROM nginx:alpine FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 8080
+3 -1
View File
@@ -1,5 +1,5 @@
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@@ -14,6 +14,8 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
} }
+3
View File
@@ -3,6 +3,9 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
+2 -2
View File
@@ -27,7 +27,7 @@ services:
context: ./apps/web context: ./apps/web
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "5173:80" - "5173:8080"
depends_on: depends_on:
- api - api
restart: unless-stopped restart: unless-stopped
@@ -41,7 +41,7 @@ services:
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"]
interval: 5s interval: 5s
+11 -18
View File
@@ -166,26 +166,19 @@ https://code-myapp-alice.tools.example.com
## 7. Tool Manifest & Orchestration ## 7. Tool Manifest & Orchestration
Tools are defined by manifests (FN-003) that declare: Tools are defined by manifest files validated against the canonical Pydantic v2 schema.
The full schema reference, validation rules, and extension guide are documented in
[`docs/tool-manifest-spec.md`](./tool-manifest-spec.md).
- Runtime image / image tag **Summary:** Each manifest declares the runtime image, ports, workspace/config mounts,
- Node/npm version expectations (for executable environments) environment variables, secrets, health checks, resource limits, and Traefik routing rules.
- Bootstrap / install commands The orchestration backend reads these manifests and generates Docker Compose service
- Command execution needs definitions, Traefik labels, and runtime volume/secret bindings.
- Workspace mounts
- Config mounts
- Environment variables
- Secrets
- Ports
- Health checks
- Resource limits
- Traefik routing needs (subdomain pattern, middleware)
The platform reads manifests and generates: Built-in manifests for RunFusion and code-server are shipped in
- Docker Compose service definitions `apps/api/app/tools/manifests/` and loaded automatically on API startup.
- Traefik labels for routing New standard-container tools can be added by creating a YAML manifest—no backend code
- Volume mounts for workspace and config changes are required.
- Secret injection at runtime
## 8. MVP Phases ## 8. MVP Phases
+209
View File
@@ -0,0 +1,209 @@
# Tool Manifest Specification
> Canonical schema reference for Headquarter's manifest-driven tool registry.
> Version: 1.0.0 — aligned with FN-003.
## Overview
Headquarter is a manifest-driven platform: every containerized tool (RunFusion, code-server, and future tools) is declared by a YAML manifest. The orchestration backend reads these manifests to generate Docker Compose services, Traefik routing labels, volume mounts, and resource constraints.
**Design goal:** Adding a new standard container tool requires only a YAML manifest—no backend code changes.
## Manifest File Format
Manifests are YAML files with a single top-level mapping. They are validated on load by Pydantic v2 models.
### Built-in location
Built-in manifests live in `apps/api/app/tools/manifests/*.yml` and are loaded automatically on API startup.
### Minimal valid manifest
```yaml
id: my-tool
name: My Tool
image: my-org/my-tool:latest
ports:
- container_port: 8080
primary: true
traefik:
enabled: false
```
## Field Reference
### `ToolManifest` (top level)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | `string` | yes | — | Lowercase slug with hyphens only (`^[a-z0-9\-]+$`). Used as the registry key. |
| `name` | `string` | yes | — | Human-readable tool name. |
| `description` | `string` | no | `""` | Short description of the tool. |
| `version` | `string` | no | `"1.0.0"` | Manifest version (semver-ish). |
| `image` | `string` | yes | — | Docker image reference. |
| `runtime_command` | `string[] \| null` | no | `null` | Override the container default command. |
| `runtime_entrypoint` | `string[] \| null` | no | `null` | Override the container entrypoint. |
| `runtime_user` | `string \| null` | no | `null` | User to run as inside the container. |
| `runtime_working_dir` | `string \| null` | no | `null` | Working directory inside the container. |
| `ports` | `PortConfig[]` | no | `[]` | Exposed ports. |
| `workspace_mounts` | `MountConfig[]` | no | `[]` | Workspace volume mounts (project-scoped). |
| `config_mounts` | `MountConfig[]` | no | `[]` | Config volume mounts (user or tool-scoped). |
| `env` | `dict<string, string>` | no | `{}` | Static environment variables. |
| `secrets` | `SecretRef[]` | no | `[]` | Secrets injected as environment variables. |
| `health_check` | `HealthCheckConfig \| null` | no | `null` | Health check definition. |
| `resource_limits` | `ResourceLimits \| null` | no | `null` | CPU and memory constraints. |
| `executable` | `ExecutableConfig \| null` | no | `null` | Node.js runtime metadata for executable environments. |
| `traefik` | `TraefikConfig \| null` | no | `null` | Traefik routing configuration. |
### `PortConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `container_port` | `int` | yes | — | Port inside the container. Range: 165535. |
| `protocol` | `"tcp" \| "udp"` | no | `"tcp"` | Transport protocol. |
| `name` | `string \| null` | no | `null` | Logical name, e.g. `"http"`, `"websocket"`. |
| `primary` | `bool` | no | `false` | The port used for default routing and health checks. |
### `MountConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | `"volume" \| "bind"` | no | `"volume"` | Mount type. |
| `source_pattern` | `string` | yes | — | Template pattern resolved at spawn time, e.g. `"{project_repo}"`. |
| `target` | `string` | yes | — | Absolute path inside the container. Must start with `/`. |
| `read_only` | `bool` | no | `false` | Mount read-only. |
### `SecretRef`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | `string` | yes | — | Secret identifier in the secret store. |
| `env_var` | `string` | yes | — | Name of the environment variable injected into the container. |
| `required` | `bool` | no | `true` | Whether the tool fails to start if the secret is missing. |
### `HealthCheckConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | `"http" \| "tcp" \| "command"` | no | `"http"` | Health check mechanism. |
| `path` | `string \| null` | no | `null` | HTTP path. Required when `type == "http"`. |
| `command` | `string[] \| null` | no | `null` | Command to execute. Required when `type == "command"`. |
| `port` | `int \| null` | no | `null` | Override port; defaults to the primary port if unset. |
| `interval_seconds` | `int` | no | `10` | Check interval. ≥ 1. |
| `timeout_seconds` | `int` | no | `5` | Check timeout. ≥ 1. |
| `retries` | `int` | no | `3` | Retries before marking unhealthy. ≥ 1. |
| `start_period_seconds` | `int` | no | `5` | Grace period before checks count. ≥ 0. |
### `ResourceLimits`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `cpus` | `float \| null` | no | `null` | CPU limit. If set, ≥ 0.01. |
| `memory_mb` | `int \| null` | no | `null` | Memory limit in MiB. If set, ≥ 16. |
| `memory_swap_mb` | `int \| null` | no | `null` | Swap limit in MiB. `-1` disables swap limit. |
### `ExecutableConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `node_version` | `string \| null` | no | `null` | Expected Node.js version, e.g. `"22"`, `"lts"`. |
| `npm_version` | `string \| null` | no | `null` | Expected npm version. |
| `package_manager` | `"npm" \| "pnpm" \| "yarn" \| "bun"` | no | `"npm"` | Preferred package manager. |
| `bootstrap_commands` | `string[]` | no | `[]` | One-time setup commands run on first start. |
| `install_commands` | `string[]` | no | `[]` | Commands run before the main command. |
### `TraefikConfig`
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enabled` | `bool` | no | `true` | Whether Traefik routing is generated for this tool. |
| `subdomain_prefix` | `string \| null` | no | `null` | Subdomain prefix. Defaults to the tool `id`. |
| `port` | `int \| null` | no | `null` | Container port to route traffic to. |
| `middlewares` | `string[]` | no | `[]` | Traefik middleware names to apply. |
| `strip_prefix` | `bool` | no | `false` | Strip path prefix before forwarding. |
| `entrypoint` | `string \| null` | no | `null` | Override the environment default Traefik entrypoint. |
| `cert_resolver` | `string \| null` | no | `null` | Override the environment default cert resolver. |
## Validation Rules
1. `id` must match `^[a-z0-9\-]+$` (lowercase, digits, hyphens only).
2. `MountConfig.target` must be an absolute path (`starts with "/"`).
3. When `health_check.type == "http"`, `path` must be set and non-empty.
4. When `health_check.type == "command"`, `command` must be set and non-empty.
5. `container_port` must be between 1 and 65535.
6. `cpus`, if set, must be ≥ 0.01.
7. `memory_mb`, if set, must be ≥ 16.
8. If `traefik.enabled` is `true`, at least one port must have `primary: true`.
## Example: RunFusion Manifest
```yaml
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
```
## Extension Guide: Adding a New Tool
To add a new standard container tool:
1. Create a new YAML file in `apps/api/app/tools/manifests/{tool-id}.yml`.
2. Populate all required fields (`id`, `name`, `image`, `ports`).
3. Set `traefik.enabled: true` and mark one port as `primary: true` if the tool needs HTTP routing.
4. Declare `workspace_mounts` and `config_mounts` as needed.
5. Restart the API (or call `registry.load_builtin_manifests()`).
No backend code changes are required for standard containers that expose an HTTP port and need volume mounts.
## Registry API
The in-memory registry exposes FastAPI routes under `/api/v1/tools`:
- `GET /api/v1/tools` — list all registered manifests.
- `GET /api/v1/tools/{id}` — retrieve a single manifest.
- `POST /api/v1/tools` — register a new manifest (returns 409 if `id` already exists).
Built-in manifests are loaded automatically on application startup via the FastAPI lifespan context manager.
+3
View File
@@ -12,6 +12,9 @@
"compose:up": "docker compose up --build -d", "compose:up": "docker compose up --build -d",
"compose:down": "docker compose down" "compose:down": "docker compose down"
}, },
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"packageManager": "pnpm@11.1.1", "packageManager": "pnpm@11.1.1",
"engines": { "engines": {
"node": ">=20", "node": ">=20",