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:
@@ -5,10 +5,14 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -e "."
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
|
||||
# Deployment
|
||||
root_domain: str = "localhost"
|
||||
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
|
||||
|
||||
@@ -9,10 +9,13 @@ from sqlalchemy import text
|
||||
from app.config import settings
|
||||
from app.db import AsyncSessionLocal, engine
|
||||
from app.routers import routers
|
||||
from app.tools.registry import registry
|
||||
from app.tools.router import router as tools_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
registry.load_builtin_manifests()
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
@@ -41,6 +44,8 @@ app.add_middleware(
|
||||
for router in routers:
|
||||
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||
|
||||
app.include_router(tools_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
id: code-server
|
||||
name: code-server
|
||||
description: VS Code in the browser.
|
||||
version: "1.0.0"
|
||||
image: codercom/code-server:latest
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 8080
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/code-server"
|
||||
target: /home/coder/.config/code-server
|
||||
read_only: false
|
||||
env: {}
|
||||
secrets:
|
||||
- name: code-server-password
|
||||
env_var: PASSWORD
|
||||
required: false
|
||||
health_check:
|
||||
type: http
|
||||
path: /healthz
|
||||
port: 8080
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 5
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 4096
|
||||
memory_swap_mb: -1
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: code
|
||||
port: 8080
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
@@ -0,0 +1,46 @@
|
||||
id: runfusion
|
||||
name: RunFusion
|
||||
description: Executable Node.js environment for running and developing applications.
|
||||
version: "1.0.0"
|
||||
image: node:22-slim
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 8080
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/runfusion"
|
||||
target: /home/node/.config
|
||||
read_only: false
|
||||
env:
|
||||
NODE_ENV: development
|
||||
health_check:
|
||||
type: http
|
||||
path: /
|
||||
port: 8080
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 10
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 2048
|
||||
memory_swap_mb: -1
|
||||
executable:
|
||||
node_version: "22"
|
||||
package_manager: npm
|
||||
bootstrap_commands: []
|
||||
install_commands: []
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: runfusion
|
||||
port: 8080
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pydantic v2 models for the Headquarter tool manifest schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class PortConfig(BaseModel):
|
||||
container_port: int = Field(..., ge=1, le=65535)
|
||||
protocol: Literal["tcp", "udp"] = "tcp"
|
||||
name: str | None = None
|
||||
primary: bool = False
|
||||
|
||||
|
||||
class MountConfig(BaseModel):
|
||||
type: Literal["volume", "bind"] = "volume"
|
||||
source_pattern: str
|
||||
target: str
|
||||
read_only: bool = False
|
||||
|
||||
@field_validator("target")
|
||||
@classmethod
|
||||
def _target_must_be_absolute(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("mount target must be an absolute path")
|
||||
return v
|
||||
|
||||
|
||||
class SecretRef(BaseModel):
|
||||
name: str
|
||||
env_var: str
|
||||
required: bool = True
|
||||
|
||||
|
||||
class HealthCheckConfig(BaseModel):
|
||||
type: Literal["http", "tcp", "command"] = "http"
|
||||
path: str | None = None
|
||||
command: list[str] | None = None
|
||||
port: int | None = None
|
||||
interval_seconds: int = Field(default=10, ge=1)
|
||||
timeout_seconds: int = Field(default=5, ge=1)
|
||||
retries: int = Field(default=3, ge=1)
|
||||
start_period_seconds: int = Field(default=5, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_required_fields(self) -> HealthCheckConfig:
|
||||
if self.type == "http" and not self.path:
|
||||
raise ValueError('path is required when health_check.type is "http"')
|
||||
if self.type == "command" and not self.command:
|
||||
raise ValueError('command is required when health_check.type is "command"')
|
||||
return self
|
||||
|
||||
|
||||
class ResourceLimits(BaseModel):
|
||||
cpus: float | None = Field(None, ge=0.01)
|
||||
memory_mb: int | None = Field(None, ge=16)
|
||||
memory_swap_mb: int | None = Field(None, ge=-1)
|
||||
|
||||
|
||||
class ExecutableConfig(BaseModel):
|
||||
node_version: str | None = None
|
||||
npm_version: str | None = None
|
||||
package_manager: Literal["npm", "pnpm", "yarn", "bun"] = "npm"
|
||||
bootstrap_commands: list[str] = []
|
||||
install_commands: list[str] = []
|
||||
|
||||
|
||||
class TraefikConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
subdomain_prefix: str | None = None
|
||||
port: int | None = None
|
||||
middlewares: list[str] = []
|
||||
strip_prefix: bool = False
|
||||
entrypoint: str | None = None
|
||||
cert_resolver: str | None = None
|
||||
|
||||
|
||||
class ToolManifest(BaseModel):
|
||||
id: str = Field(..., pattern=r"^[a-z0-9\-]+$")
|
||||
name: str
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
image: str
|
||||
runtime_command: list[str] | None = None
|
||||
runtime_entrypoint: list[str] | None = None
|
||||
runtime_user: str | None = None
|
||||
runtime_working_dir: str | None = None
|
||||
ports: list[PortConfig] = []
|
||||
workspace_mounts: list[MountConfig] = []
|
||||
config_mounts: list[MountConfig] = []
|
||||
env: dict[str, str] = {}
|
||||
secrets: list[SecretRef] = []
|
||||
health_check: HealthCheckConfig | None = None
|
||||
resource_limits: ResourceLimits | None = None
|
||||
executable: ExecutableConfig | None = None
|
||||
traefik: TraefikConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_traefik_primary_port(self) -> ToolManifest:
|
||||
traefik = self.traefik
|
||||
if traefik is not None and traefik.enabled:
|
||||
has_primary = any(port.primary for port in self.ports)
|
||||
if not has_primary:
|
||||
raise ValueError(
|
||||
"at least one port must have primary=True when traefik.enabled is True"
|
||||
)
|
||||
return self
|
||||
@@ -0,0 +1,52 @@
|
||||
"""In-memory tool manifest registry with YAML file loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.tools.models import ToolManifest
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""In-memory registry for tool manifests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._manifests: dict[str, ToolManifest] = {}
|
||||
|
||||
def load_builtin_manifests(self) -> None:
|
||||
"""Scan the built-in manifests directory and register all *.yml files."""
|
||||
manifests_dir = Path(__file__).parent / "manifests"
|
||||
if not manifests_dir.exists():
|
||||
return
|
||||
for file_path in sorted(manifests_dir.glob("*.yml")):
|
||||
self.load_file(file_path)
|
||||
|
||||
def load_file(self, path: Path) -> ToolManifest:
|
||||
"""Load a single YAML manifest file, validate it, and register it."""
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
manifest = ToolManifest.model_validate(data)
|
||||
self.register(manifest)
|
||||
return manifest
|
||||
|
||||
def register(self, manifest: ToolManifest) -> None:
|
||||
"""Store a manifest in the registry (idempotent upsert)."""
|
||||
self._manifests[manifest.id] = manifest
|
||||
|
||||
def get(self, tool_id: str) -> ToolManifest | None:
|
||||
"""Retrieve a manifest by tool id, or None if not found."""
|
||||
return self._manifests.get(tool_id)
|
||||
|
||||
def list(self) -> list[ToolManifest]:
|
||||
"""Return all registered manifests."""
|
||||
return list(self._manifests.values())
|
||||
|
||||
def remove(self, tool_id: str) -> ToolManifest | None:
|
||||
"""Remove a manifest by tool id and return it, or None if not found."""
|
||||
return self._manifests.pop(tool_id, None)
|
||||
|
||||
|
||||
# Module-level singleton — callers must explicitly bootstrap via
|
||||
# registry.load_builtin_manifests() (typically in a FastAPI lifespan).
|
||||
registry = ToolRegistry()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""FastAPI routes for the tool manifest registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.tools.models import ToolManifest
|
||||
from app.tools.registry import registry
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolManifest])
|
||||
def list_tools() -> list[ToolManifest]:
|
||||
return registry.list()
|
||||
|
||||
|
||||
@router.get("/{tool_id}", response_model=ToolManifest)
|
||||
def get_tool(tool_id: str) -> ToolManifest:
|
||||
manifest = registry.get(tool_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool '{tool_id}' not found",
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
@router.post("", response_model=ToolManifest, status_code=status.HTTP_201_CREATED)
|
||||
def create_tool(manifest: ToolManifest) -> ToolManifest:
|
||||
if registry.get(manifest.id) is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Tool '{manifest.id}' already exists",
|
||||
)
|
||||
registry.register(manifest)
|
||||
return manifest
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"pyjwt>=2.8.0",
|
||||
"cryptography>=44.0.0",
|
||||
"httpx>=0.28.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -46,6 +47,10 @@ strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
exclude = ["alembic/versions"]
|
||||
plugins = ["pydantic.mypy"]
|
||||
[[tool.mypy.overrides]]
|
||||
module = "yaml"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
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
|
||||
Reference in New Issue
Block a user