2026-02-07 07:45:57 +01:00
|
|
|
"""Tests for environment builder orchestration."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
from mcp_forge.builder.environment_builder import (
|
|
|
|
|
EnvironmentBuilder,
|
|
|
|
|
BuildRateLimiter,
|
|
|
|
|
)
|
|
|
|
|
from mcp_forge.builder.package_validator import SecurityError
|
|
|
|
|
from mcp_forge.builder.image_builder import BuildResult
|
|
|
|
|
from mcp_forge.config.schema import EnvironmentBuilderConfig
|
2026-03-05 06:45:00 +01:00
|
|
|
from pod_executor.containers.client import PodmanClient
|
2026-02-07 07:45:57 +01:00
|
|
|
from mcp_forge.security.audit import AuditLogger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def builder_config():
|
|
|
|
|
"""Mock environment builder configuration."""
|
|
|
|
|
config = Mock(spec=EnvironmentBuilderConfig)
|
|
|
|
|
config.build_timeout = 600
|
|
|
|
|
config.max_build_timeout = 1800
|
|
|
|
|
config.max_image_size = "2g"
|
|
|
|
|
config.max_packages = 50
|
|
|
|
|
config.uv_cache_path = Path("/tmp/uv-cache")
|
|
|
|
|
config.build_rate_limit = {"requests": 5, "period": 3600}
|
|
|
|
|
config.max_concurrent_builds = 3
|
|
|
|
|
config.base_images = {"python:3.11-slim": True}
|
|
|
|
|
config.templates = {
|
|
|
|
|
"data-science": {
|
|
|
|
|
"packages": ["numpy", "pandas", "matplotlib"],
|
|
|
|
|
"description": "Data science environment"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
# Add package_validation config for PackageValidator
|
|
|
|
|
config.package_validation = Path("/tmp/package-validation.yaml")
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_podman_client():
|
|
|
|
|
"""Mock Podman client."""
|
|
|
|
|
return Mock(spec=PodmanClient)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_audit_logger():
|
|
|
|
|
"""Mock audit logger."""
|
|
|
|
|
return Mock(spec=AuditLogger)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def builder(builder_config, mock_podman_client, mock_audit_logger):
|
|
|
|
|
"""Environment builder instance."""
|
|
|
|
|
# Mock the sub-components during initialization
|
|
|
|
|
with patch('mcp_forge.builder.environment_builder.PackageValidator'), \
|
|
|
|
|
patch('mcp_forge.builder.environment_builder.UVInstaller'), \
|
|
|
|
|
patch('mcp_forge.builder.environment_builder.ImageBuilder'):
|
|
|
|
|
|
|
|
|
|
builder = EnvironmentBuilder(
|
|
|
|
|
config=builder_config,
|
|
|
|
|
podman_client=mock_podman_client,
|
|
|
|
|
audit_logger=mock_audit_logger
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Replace with mocks for tests
|
|
|
|
|
builder.package_validator = Mock()
|
|
|
|
|
builder.uv_installer = Mock()
|
|
|
|
|
builder.image_builder = Mock()
|
|
|
|
|
|
|
|
|
|
return builder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_custom_environment_success(builder, tmp_path):
|
|
|
|
|
"""Test successful custom environment build."""
|
|
|
|
|
# Mock all sub-components
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
|
|
|
|
|
mock_result = BuildResult(
|
|
|
|
|
success=True,
|
|
|
|
|
image_name="mcp-forge/custom:test-env",
|
|
|
|
|
image_id="sha256:abc123",
|
|
|
|
|
build_time=45.2,
|
|
|
|
|
size_bytes=500_000_000,
|
|
|
|
|
cache_hit=False,
|
|
|
|
|
installed_packages=["numpy==1.24.0"]
|
|
|
|
|
)
|
|
|
|
|
builder.image_builder.build_image = Mock(return_value=mock_result)
|
|
|
|
|
|
|
|
|
|
result = builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["numpy>=1.24.0"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
assert result.image_name == "mcp-forge/custom:test-env"
|
|
|
|
|
builder.package_validator.validate_packages.assert_called_once()
|
|
|
|
|
builder.image_builder.build_image.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_validates_package_count(builder):
|
|
|
|
|
"""Test that build validates package count against maximum."""
|
|
|
|
|
builder.config.max_packages = 10
|
|
|
|
|
|
|
|
|
|
packages = [f"package{i}" for i in range(20)]
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="exceeds maximum"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=packages,
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_validates_packages_with_validator(builder):
|
|
|
|
|
"""Test that build uses package validator."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock(
|
|
|
|
|
side_effect=SecurityError("Blocked package")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SecurityError, match="Blocked package"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["forbidden-package"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_enforces_rate_limit(builder):
|
|
|
|
|
"""Test that build enforces rate limiting."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
|
|
|
|
|
# Exhaust rate limit
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
builder.rate_limiter.check_rate_limit("user123")
|
|
|
|
|
|
|
|
|
|
# Next request should fail
|
|
|
|
|
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["numpy"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_enforces_concurrent_builds_limit(builder, tmp_path):
|
|
|
|
|
"""Test that build enforces concurrent builds limit."""
|
|
|
|
|
builder.config.max_concurrent_builds = 2
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
|
|
|
|
|
# Simulate 2 active builds
|
|
|
|
|
builder.active_builds.add("build1")
|
|
|
|
|
builder.active_builds.add("build2")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="Maximum concurrent builds"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["numpy"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_cleans_up_build_context(builder, tmp_path):
|
|
|
|
|
"""Test that build cleans up build context even on failure."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
builder.image_builder.build_image = Mock(side_effect=RuntimeError("Build failed"))
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="Build failed"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["numpy"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Build context should have been registered for cleanup
|
|
|
|
|
# (actual cleanup would happen in finally block)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_validates_environment_name(builder):
|
|
|
|
|
"""Test that build validates environment name format."""
|
|
|
|
|
with pytest.raises(ValueError, match="must contain only alphanumeric"):
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test env!", # Invalid: spaces and special chars
|
|
|
|
|
packages=["numpy"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_from_template_expands_packages(builder, tmp_path):
|
|
|
|
|
"""Test building from template expands package list."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
|
|
|
|
|
mock_result = BuildResult(
|
|
|
|
|
success=True,
|
|
|
|
|
image_name="mcp-forge/custom:data-sci",
|
|
|
|
|
image_id="sha256:abc123",
|
|
|
|
|
build_time=45.2,
|
|
|
|
|
size_bytes=500_000_000,
|
|
|
|
|
cache_hit=False,
|
|
|
|
|
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0"]
|
|
|
|
|
)
|
|
|
|
|
builder.image_builder.build_image = Mock(return_value=mock_result)
|
|
|
|
|
|
|
|
|
|
result = builder.build_from_template(
|
|
|
|
|
template_name="data-science",
|
|
|
|
|
name="data-sci",
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
# Should have called validator with template packages
|
|
|
|
|
builder.package_validator.validate_packages.assert_called_once()
|
|
|
|
|
call_args = builder.package_validator.validate_packages.call_args[0][0]
|
|
|
|
|
assert "numpy" in call_args
|
|
|
|
|
assert "pandas" in call_args
|
|
|
|
|
assert "matplotlib" in call_args
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_from_template_with_additional_packages(builder, tmp_path):
|
|
|
|
|
"""Test building from template with additional packages."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
|
|
|
|
|
mock_result = BuildResult(
|
|
|
|
|
success=True,
|
|
|
|
|
image_name="mcp-forge/custom:data-sci",
|
|
|
|
|
image_id="sha256:abc123",
|
|
|
|
|
build_time=45.2,
|
|
|
|
|
size_bytes=500_000_000,
|
|
|
|
|
cache_hit=False,
|
|
|
|
|
installed_packages=["numpy==1.24.0", "pandas==2.0.0", "matplotlib==3.7.0", "scipy==1.10.0"]
|
|
|
|
|
)
|
|
|
|
|
builder.image_builder.build_image = Mock(return_value=mock_result)
|
|
|
|
|
|
|
|
|
|
result = builder.build_from_template(
|
|
|
|
|
template_name="data-science",
|
|
|
|
|
additional_packages=["scipy"],
|
|
|
|
|
name="data-sci",
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
call_args = builder.package_validator.validate_packages.call_args[0][0]
|
|
|
|
|
assert "scipy" in call_args
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_from_template_validates_template_exists(builder):
|
|
|
|
|
"""Test that template build validates template exists."""
|
|
|
|
|
with pytest.raises(ValueError, match="Template.*not found"):
|
|
|
|
|
builder.build_from_template(
|
|
|
|
|
template_name="nonexistent",
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_templates_returns_config_templates(builder):
|
|
|
|
|
"""Test that list_templates returns configured templates."""
|
|
|
|
|
templates = builder.list_templates()
|
|
|
|
|
|
|
|
|
|
assert "data-science" in templates
|
|
|
|
|
assert templates["data-science"]["packages"] == ["numpy", "pandas", "matplotlib"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rate_limiter_allows_within_limit():
|
|
|
|
|
"""Test that rate limiter allows requests within limit."""
|
|
|
|
|
limiter = BuildRateLimiter(max_requests=5, period_seconds=3600)
|
|
|
|
|
|
|
|
|
|
# Should allow 5 requests
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
limiter.check_rate_limit("user123")
|
|
|
|
|
|
|
|
|
|
# 6th request should fail
|
|
|
|
|
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
|
|
|
|
|
limiter.check_rate_limit("user123")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rate_limiter_cleans_up_old_requests():
|
|
|
|
|
"""Test that rate limiter cleans up old requests."""
|
|
|
|
|
limiter = BuildRateLimiter(max_requests=5, period_seconds=1)
|
|
|
|
|
|
|
|
|
|
# Make 5 requests
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
limiter.check_rate_limit("user123")
|
|
|
|
|
|
|
|
|
|
# Wait for period to expire
|
|
|
|
|
import time
|
|
|
|
|
time.sleep(1.1)
|
|
|
|
|
|
|
|
|
|
# Should allow new request after period
|
|
|
|
|
limiter.check_rate_limit("user123")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rate_limiter_tracks_per_user():
|
|
|
|
|
"""Test that rate limiter tracks requests per user."""
|
|
|
|
|
limiter = BuildRateLimiter(max_requests=2, period_seconds=3600)
|
|
|
|
|
|
|
|
|
|
# User1 makes 2 requests
|
|
|
|
|
limiter.check_rate_limit("user1")
|
|
|
|
|
limiter.check_rate_limit("user1")
|
|
|
|
|
|
|
|
|
|
# User2 should still be allowed
|
|
|
|
|
limiter.check_rate_limit("user2")
|
|
|
|
|
limiter.check_rate_limit("user2")
|
|
|
|
|
|
|
|
|
|
# Both users now at limit
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
limiter.check_rate_limit("user1")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
limiter.check_rate_limit("user2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_registers_and_unregisters_active_builds(builder, tmp_path):
|
|
|
|
|
"""Test that builds are registered and unregistered correctly."""
|
|
|
|
|
builder.package_validator.validate_packages = Mock()
|
|
|
|
|
builder.uv_installer.create_build_context = Mock(return_value=tmp_path)
|
|
|
|
|
|
|
|
|
|
mock_result = BuildResult(
|
|
|
|
|
success=True,
|
|
|
|
|
image_name="mcp-forge/custom:test-env",
|
|
|
|
|
image_id="sha256:abc123",
|
|
|
|
|
build_time=45.2,
|
|
|
|
|
size_bytes=500_000_000,
|
|
|
|
|
cache_hit=False,
|
|
|
|
|
installed_packages=["numpy==1.24.0"]
|
|
|
|
|
)
|
|
|
|
|
builder.image_builder.build_image = Mock(return_value=mock_result)
|
|
|
|
|
|
|
|
|
|
# Before build
|
|
|
|
|
assert "test-env" not in builder.active_builds
|
|
|
|
|
|
|
|
|
|
builder.build_custom_environment(
|
|
|
|
|
name="test-env",
|
|
|
|
|
packages=["numpy"],
|
|
|
|
|
user_id="user123"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# After build
|
|
|
|
|
assert "test-env" not in builder.active_builds
|