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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ignore-build-scripts=false
|
||||||
+2
-2
@@ -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
@@ -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,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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: 1–65535. |
|
||||||
|
| `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.
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user