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:
@@ -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"
|
||||
Reference in New Issue
Block a user