f33a563003
- 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
244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
"""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"
|