initial commit after one day coding agent session
This commit is contained in:
commit
372af75b90
88 changed files with 22694 additions and 0 deletions
346
tests/builder/test_environment_builder.py
Normal file
346
tests/builder/test_environment_builder.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""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
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
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
|
||||
355
tests/builder/test_image_builder.py
Normal file
355
tests/builder/test_image_builder.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""Tests for Image Builder module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
from mcp_forge.builder.image_builder import ImageBuilder, BuildResult
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig
|
||||
from mcp_forge.security.audit import AuditLogger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_podman_client():
|
||||
"""Mock PodmanClient."""
|
||||
mock = Mock(spec=PodmanClient)
|
||||
# Configure nested mocks for images and containers
|
||||
mock.images = Mock()
|
||||
mock.containers = Mock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder_config():
|
||||
"""Mock EnvironmentBuilderConfig."""
|
||||
config = Mock(spec=EnvironmentBuilderConfig)
|
||||
config.enabled = True
|
||||
config.uv_cache_path = Path("/tmp/uv_cache")
|
||||
config.max_packages_per_build = 50
|
||||
config.build_timeout = 600
|
||||
config.max_build_timeout = 1800
|
||||
config.max_image_size = "2g"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audit_logger():
|
||||
"""Mock AuditLogger."""
|
||||
return Mock(spec=AuditLogger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(mock_podman_client, builder_config, mock_audit_logger):
|
||||
"""ImageBuilder instance."""
|
||||
return ImageBuilder(mock_podman_client, builder_config, mock_audit_logger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_context(tmp_path):
|
||||
"""Create temporary build context."""
|
||||
context = tmp_path / "build_context"
|
||||
context.mkdir()
|
||||
|
||||
# Create Containerfile
|
||||
containerfile = context / "Containerfile"
|
||||
containerfile.write_text("FROM python:3.11-slim\n")
|
||||
|
||||
# Create requirements.txt
|
||||
requirements = context / "requirements.txt"
|
||||
requirements.write_text("numpy>=1.24.0\npandas\n")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def test_build_image_success(builder, build_context, mock_podman_client):
|
||||
"""Test successful image build."""
|
||||
# Mock successful build
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 500_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image # Mock images.get() call
|
||||
|
||||
# Mock pip list output
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (
|
||||
0,
|
||||
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
|
||||
)
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
result = builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=packages
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.image_name == "mcp-forge/custom:test-env"
|
||||
assert result.image_id == "sha256:abc123"
|
||||
assert result.size_bytes == 500_000_000
|
||||
assert len(result.installed_packages) == 2
|
||||
|
||||
|
||||
def test_build_image_with_custom_timeout(builder, build_context, mock_podman_client):
|
||||
"""Test build with custom timeout."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[],
|
||||
timeout=1200
|
||||
)
|
||||
|
||||
# Verify timeout was passed to build
|
||||
call_kwargs = mock_podman_client.images.build.call_args[1]
|
||||
assert call_kwargs["timeout"] == 1200
|
||||
|
||||
|
||||
def test_build_image_validates_timeout_against_max(builder, build_context, builder_config):
|
||||
"""Test that build validates timeout against maximum."""
|
||||
builder_config.max_build_timeout = 1800
|
||||
|
||||
with pytest.raises(ValueError, match="Timeout 3600 exceeds maximum"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[],
|
||||
timeout=3600
|
||||
)
|
||||
|
||||
|
||||
def test_build_image_uses_default_timeout(builder, build_context, builder_config, mock_podman_client):
|
||||
"""Test that build uses config default timeout when not specified."""
|
||||
builder_config.build_timeout = 600
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
call_kwargs = mock_podman_client.images.build.call_args[1]
|
||||
assert call_kwargs["timeout"] == 600
|
||||
|
||||
|
||||
def test_generate_tag_valid_name(builder):
|
||||
"""Test tag generation with valid name."""
|
||||
tag = builder.generate_tag("my-env-123")
|
||||
assert tag == "mcp-forge/custom:my-env-123"
|
||||
|
||||
|
||||
def test_generate_tag_invalid_name_raises_error(builder):
|
||||
"""Test that invalid names raise ValueError."""
|
||||
with pytest.raises(ValueError, match="alphanumeric"):
|
||||
builder.generate_tag("my env")
|
||||
|
||||
with pytest.raises(ValueError, match="alphanumeric"):
|
||||
builder.generate_tag("my_env!")
|
||||
|
||||
|
||||
def test_validate_image_size_within_limit(builder, mock_podman_client, builder_config):
|
||||
"""Test image size validation passes when within limit."""
|
||||
builder_config.max_image_size = "2g"
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.attrs = {"Size": 1_000_000_000} # 1GB
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
|
||||
size = builder.validate_image_size("sha256:abc123")
|
||||
|
||||
assert size == 1_000_000_000
|
||||
|
||||
|
||||
def test_validate_image_size_exceeds_limit(builder, mock_podman_client, builder_config):
|
||||
"""Test image size validation fails when exceeds limit."""
|
||||
builder_config.max_image_size = "1g"
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.attrs = {"Size": 2_000_000_000} # 2GB
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum"):
|
||||
builder.validate_image_size("sha256:abc123")
|
||||
|
||||
|
||||
def test_extract_installed_packages(builder, mock_podman_client):
|
||||
"""Test extracting installed packages from image."""
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (
|
||||
0,
|
||||
b'[{"name": "numpy", "version": "1.24.0"}, {"name": "pandas", "version": "2.0.0"}]'
|
||||
)
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = builder.extract_installed_packages("sha256:abc123")
|
||||
|
||||
assert len(packages) == 2
|
||||
assert "numpy==1.24.0" in packages
|
||||
assert "pandas==2.0.0" in packages
|
||||
|
||||
|
||||
def test_extract_installed_packages_handles_error(builder, mock_podman_client):
|
||||
"""Test that package extraction handles errors gracefully."""
|
||||
mock_container = Mock()
|
||||
mock_container.exec_run.return_value = (1, b"Error")
|
||||
mock_podman_client.containers.run.return_value = mock_container
|
||||
|
||||
packages = builder.extract_installed_packages("sha256:abc123")
|
||||
|
||||
assert packages == []
|
||||
|
||||
|
||||
def test_calculate_cache_hash(builder):
|
||||
"""Test cache hash calculation."""
|
||||
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
|
||||
|
||||
hash1 = builder.calculate_cache_hash(packages)
|
||||
hash2 = builder.calculate_cache_hash(packages)
|
||||
|
||||
# Same packages should produce same hash
|
||||
assert hash1 == hash2
|
||||
|
||||
# Different packages should produce different hash
|
||||
different_packages = ["numpy>=1.24.0", "scipy"]
|
||||
hash3 = builder.calculate_cache_hash(different_packages)
|
||||
assert hash1 != hash3
|
||||
|
||||
|
||||
def test_calculate_cache_hash_order_independent(builder):
|
||||
"""Test that cache hash is order-independent."""
|
||||
packages1 = ["numpy", "pandas", "scipy"]
|
||||
packages2 = ["scipy", "numpy", "pandas"]
|
||||
|
||||
hash1 = builder.calculate_cache_hash(packages1)
|
||||
hash2 = builder.calculate_cache_hash(packages2)
|
||||
|
||||
# Order shouldn't matter
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_build_image_logs_audit_event(builder, build_context, mock_podman_client, mock_audit_logger):
|
||||
"""Test that build logs audit event."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=["numpy"]
|
||||
)
|
||||
|
||||
# Verify audit log was called
|
||||
assert mock_audit_logger.log.called
|
||||
|
||||
|
||||
def test_build_image_returns_build_time(builder, build_context, mock_podman_client):
|
||||
"""Test that build result includes build time."""
|
||||
mock_image = Mock()
|
||||
mock_image.id = "sha256:abc123"
|
||||
mock_image.attrs = {"Size": 100_000_000}
|
||||
|
||||
mock_podman_client.images.build.return_value = (mock_image, [])
|
||||
mock_podman_client.images.get.return_value = mock_image
|
||||
mock_podman_client.containers.run.return_value.exec_run.return_value = (0, b"[]")
|
||||
|
||||
result = builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
assert result.build_time > 0
|
||||
|
||||
|
||||
def test_build_image_handles_build_failure(builder, build_context, mock_podman_client):
|
||||
"""Test that build handles Podman build failures."""
|
||||
mock_podman_client.images.build.side_effect = Exception("Build failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Build failed"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=build_context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
|
||||
def test_build_result_to_dict(builder):
|
||||
"""Test BuildResult serialization."""
|
||||
result = BuildResult(
|
||||
success=True,
|
||||
image_name="mcp-forge/custom:test",
|
||||
image_id="sha256:abc123",
|
||||
build_time=10.5,
|
||||
size_bytes=500_000_000,
|
||||
cache_hit=False,
|
||||
installed_packages=["numpy==1.24.0", "pandas==2.0.0"]
|
||||
)
|
||||
|
||||
result_dict = result.to_dict()
|
||||
|
||||
assert result_dict["success"] is True
|
||||
assert result_dict["image_name"] == "mcp-forge/custom:test"
|
||||
assert result_dict["image_id"] == "sha256:abc123"
|
||||
assert result_dict["build_time"] == 10.5
|
||||
assert result_dict["size_bytes"] == 500_000_000
|
||||
assert result_dict["cache_hit"] is False
|
||||
assert len(result_dict["installed_packages"]) == 2
|
||||
|
||||
|
||||
def test_build_image_validates_build_context_exists(builder, tmp_path):
|
||||
"""Test that build validates build context exists."""
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
|
||||
with pytest.raises(ValueError, match="Build context does not exist"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=nonexistent,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
|
||||
|
||||
def test_build_image_validates_containerfile_exists(builder, tmp_path):
|
||||
"""Test that build validates Containerfile exists."""
|
||||
context = tmp_path / "context"
|
||||
context.mkdir()
|
||||
# No Containerfile created
|
||||
|
||||
with pytest.raises(ValueError, match="Containerfile not found"):
|
||||
builder.build_image(
|
||||
name="test-env",
|
||||
build_context=context,
|
||||
base_image="python:3.11-slim",
|
||||
packages=[]
|
||||
)
|
||||
271
tests/builder/test_package_validator.py
Normal file
271
tests/builder/test_package_validator.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Tests for Package Validator module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from mcp_forge.builder.package_validator import (
|
||||
PackageValidator,
|
||||
ApprovalRequiredError,
|
||||
SecurityError
|
||||
)
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def allowlist_file(tmp_path):
|
||||
"""Create temporary allowlist file."""
|
||||
allowlist = tmp_path / "allowlist.txt"
|
||||
allowlist.write_text("""
|
||||
# Standard data science packages
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
scikit-learn
|
||||
matplotlib
|
||||
|
||||
# Web and API
|
||||
requests
|
||||
httpx
|
||||
aiohttp
|
||||
|
||||
# Utilities
|
||||
pyyaml
|
||||
python-dateutil
|
||||
""".strip())
|
||||
return allowlist
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blocklist_file(tmp_path):
|
||||
"""Create temporary blocklist file."""
|
||||
blocklist = tmp_path / "blocklist.txt"
|
||||
blocklist.write_text("""
|
||||
# Security concerns
|
||||
os-crypto
|
||||
subprocess-wrapper
|
||||
shell-exec
|
||||
|
||||
# Known malicious
|
||||
malicious-package
|
||||
evil-lib
|
||||
""".strip())
|
||||
return blocklist
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validation_config(allowlist_file, blocklist_file, tmp_path):
|
||||
"""Mock PackageValidationConfig."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = allowlist_file
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = [
|
||||
"^torch.*", # PyTorch packages
|
||||
"^tensorflow.*", # TensorFlow packages
|
||||
".*-gpu$", # GPU variants
|
||||
]
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator(validation_config):
|
||||
"""PackageValidator instance."""
|
||||
return PackageValidator(validation_config)
|
||||
|
||||
|
||||
def test_extract_package_name_simple(validator):
|
||||
"""Test extracting package name from simple spec."""
|
||||
assert validator.extract_package_name("numpy") == "numpy"
|
||||
assert validator.extract_package_name("pandas") == "pandas"
|
||||
|
||||
|
||||
def test_extract_package_name_with_version(validator):
|
||||
"""Test extracting package name with version specifiers."""
|
||||
assert validator.extract_package_name("numpy>=1.24.0") == "numpy"
|
||||
assert validator.extract_package_name("pandas==2.0.0") == "pandas"
|
||||
assert validator.extract_package_name("requests<=2.28.0") == "requests"
|
||||
assert validator.extract_package_name("scikit-learn~=1.3.0") == "scikit-learn"
|
||||
|
||||
|
||||
def test_extract_package_name_with_extras(validator):
|
||||
"""Test extracting package name with extras."""
|
||||
assert validator.extract_package_name("requests[security]") == "requests"
|
||||
assert validator.extract_package_name("pandas[excel,sql]") == "pandas"
|
||||
|
||||
|
||||
def test_extract_package_name_complex(validator):
|
||||
"""Test extracting package name from complex specs."""
|
||||
assert validator.extract_package_name("numpy>=1.24.0,<2.0.0") == "numpy"
|
||||
assert validator.extract_package_name("requests[security]>=2.28.0") == "requests"
|
||||
|
||||
|
||||
def test_allowlisted_package_passes(validator):
|
||||
"""Test that allowlisted packages pass validation."""
|
||||
validator.validate_package("numpy")
|
||||
validator.validate_package("pandas>=2.0.0")
|
||||
validator.validate_package("requests[security]")
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_blocklisted_package_raises_error(validator):
|
||||
"""Test that blocklisted packages raise SecurityError."""
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_package("malicious-package")
|
||||
|
||||
with pytest.raises(SecurityError, match="evil-lib"):
|
||||
validator.validate_package("evil-lib>=1.0.0")
|
||||
|
||||
|
||||
def test_unknown_package_with_allowlist_raises_error(validator):
|
||||
"""Test that unknown packages raise error when allowlist is enabled."""
|
||||
with pytest.raises(SecurityError, match="unknown-package"):
|
||||
validator.validate_package("unknown-package")
|
||||
|
||||
|
||||
def test_package_requiring_approval_raises_error(validator):
|
||||
"""Test that packages matching approval patterns raise ApprovalRequiredError."""
|
||||
with pytest.raises(ApprovalRequiredError, match="torch"):
|
||||
validator.validate_package("torch")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="tensorflow"):
|
||||
validator.validate_package("tensorflow-gpu")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="gpu"):
|
||||
validator.validate_package("cupy-gpu")
|
||||
|
||||
|
||||
def test_validate_packages_list(validator):
|
||||
"""Test validating multiple packages at once."""
|
||||
packages = ["numpy>=1.24.0", "pandas", "requests"]
|
||||
validator.validate_packages(packages)
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_validate_packages_enforces_max_limit(validator):
|
||||
"""Test that validate_packages enforces maximum package count."""
|
||||
packages = ["numpy", "pandas", "scipy", "matplotlib"]
|
||||
|
||||
with pytest.raises(ValueError, match="Maximum 3 packages"):
|
||||
validator.validate_packages(packages, max_packages=3)
|
||||
|
||||
|
||||
def test_validate_packages_with_mixed_results(validator):
|
||||
"""Test that validation stops at first error."""
|
||||
packages = ["numpy", "malicious-package", "pandas"]
|
||||
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_validate_packages_with_approval_required(validator):
|
||||
"""Test that validation stops at first approval requirement."""
|
||||
packages = ["numpy", "torch", "pandas"]
|
||||
|
||||
with pytest.raises(ApprovalRequiredError, match="torch"):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_allowlist_loading(allowlist_file):
|
||||
"""Test that allowlist is loaded correctly from file."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = allowlist_file
|
||||
config.blocklist_path = None
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
assert "numpy" in validator.allowlist
|
||||
assert "pandas" in validator.allowlist
|
||||
assert "requests" in validator.allowlist
|
||||
# Comments and empty lines should be ignored
|
||||
assert "# Standard data science packages" not in validator.allowlist
|
||||
|
||||
|
||||
def test_blocklist_loading(blocklist_file):
|
||||
"""Test that blocklist is loaded correctly from file."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = False
|
||||
config.allowlist_path = None
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
assert "malicious-package" in validator.blocklist
|
||||
assert "evil-lib" in validator.blocklist
|
||||
|
||||
|
||||
def test_disabled_allowlist_allows_all_except_blocklist(blocklist_file):
|
||||
"""Test that disabling allowlist allows any package except blocklisted."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = False
|
||||
config.allowlist_path = None
|
||||
config.blocklist_path = blocklist_file
|
||||
config.require_approval_patterns = []
|
||||
|
||||
validator = PackageValidator(config)
|
||||
|
||||
# Unknown packages should pass
|
||||
validator.validate_package("some-random-package")
|
||||
|
||||
# But blocklisted packages should still fail
|
||||
with pytest.raises(SecurityError, match="malicious-package"):
|
||||
validator.validate_package("malicious-package")
|
||||
|
||||
|
||||
def test_approval_pattern_matching(validator):
|
||||
"""Test that approval patterns match correctly."""
|
||||
# torch* should match
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("torch")
|
||||
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("torchvision")
|
||||
|
||||
# *-gpu$ should match
|
||||
with pytest.raises(ApprovalRequiredError):
|
||||
validator.validate_package("something-gpu")
|
||||
|
||||
|
||||
def test_empty_package_list(validator):
|
||||
"""Test validating empty package list."""
|
||||
validator.validate_packages([])
|
||||
# Should not raise
|
||||
|
||||
|
||||
def test_validate_packages_without_max_limit(validator):
|
||||
"""Test validating many packages without limit."""
|
||||
packages = [f"package{i}" for i in range(100)]
|
||||
|
||||
# Should raise because packages aren't in allowlist
|
||||
with pytest.raises(SecurityError):
|
||||
validator.validate_packages(packages)
|
||||
|
||||
|
||||
def test_case_sensitivity(validator):
|
||||
"""Test that package names are case-sensitive."""
|
||||
# numpy is in allowlist
|
||||
validator.validate_package("numpy")
|
||||
|
||||
# NumPy (different case) should fail
|
||||
with pytest.raises(SecurityError, match="NumPy"):
|
||||
validator.validate_package("NumPy")
|
||||
|
||||
|
||||
def test_whitespace_handling(validator):
|
||||
"""Test that leading/trailing whitespace is handled."""
|
||||
validator.validate_package(" numpy ")
|
||||
validator.validate_package(" pandas>=2.0.0 ")
|
||||
|
||||
|
||||
def test_file_not_found_handling(tmp_path):
|
||||
"""Test handling of missing allowlist/blocklist files."""
|
||||
config = Mock(spec=PackageValidationConfig)
|
||||
config.use_allowlist = True
|
||||
config.allowlist_path = tmp_path / "nonexistent.txt"
|
||||
config.blocklist_path = None
|
||||
config.require_approval_patterns = []
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
PackageValidator(config)
|
||||
304
tests/builder/test_uv_installer.py
Normal file
304
tests/builder/test_uv_installer.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Tests for UV Package Installer module."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from mcp_forge.builder.uv_installer import UVInstaller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_path(tmp_path):
|
||||
"""Temporary cache directory."""
|
||||
cache_dir = tmp_path / "uv_cache"
|
||||
return cache_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def installer(cache_path):
|
||||
"""UVInstaller instance."""
|
||||
return UVInstaller(cache_path)
|
||||
|
||||
|
||||
def test_init_creates_cache_directory(cache_path):
|
||||
"""Test that __init__ creates cache directory."""
|
||||
assert not cache_path.exists()
|
||||
|
||||
installer = UVInstaller(cache_path)
|
||||
|
||||
assert cache_path.exists()
|
||||
assert cache_path.is_dir()
|
||||
|
||||
|
||||
def test_init_with_existing_cache(cache_path):
|
||||
"""Test initialization with existing cache directory."""
|
||||
cache_path.mkdir(parents=True)
|
||||
|
||||
installer = UVInstaller(cache_path)
|
||||
|
||||
assert cache_path.exists()
|
||||
|
||||
|
||||
def test_generate_requirements_single_package(installer):
|
||||
"""Test generating requirements.txt with single package."""
|
||||
packages = ["numpy>=1.24.0"]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
assert requirements == "numpy>=1.24.0"
|
||||
|
||||
|
||||
def test_generate_requirements_multiple_packages(installer):
|
||||
"""Test generating requirements.txt with multiple packages."""
|
||||
packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
lines = requirements.strip().split('\n')
|
||||
assert len(lines) == 3
|
||||
assert "numpy>=1.24.0" in lines
|
||||
assert "pandas==2.0.0" in lines
|
||||
assert "requests" in lines
|
||||
|
||||
|
||||
def test_generate_requirements_empty_list(installer):
|
||||
"""Test generating requirements.txt with empty list."""
|
||||
packages = []
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
assert requirements == ""
|
||||
|
||||
|
||||
def test_generate_containerfile_structure(installer):
|
||||
"""Test Containerfile has correct structure."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Check key sections are present
|
||||
assert f"FROM {base_image}" in containerfile
|
||||
assert "pip install" in containerfile and "uv" in containerfile
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "RUN uv pip install" in containerfile
|
||||
assert "WORKDIR" in containerfile
|
||||
|
||||
|
||||
def test_generate_containerfile_with_python_version(installer):
|
||||
"""Test Containerfile generation with specific Python version."""
|
||||
base_image = "docker.io/python:3.12-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(
|
||||
base_image, packages, python_version="3.12"
|
||||
)
|
||||
|
||||
assert "3.12" in containerfile or "python:3.12" in base_image
|
||||
|
||||
|
||||
def test_generate_containerfile_uses_requirements(installer):
|
||||
"""Test Containerfile copies and uses requirements.txt."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should copy requirements.txt for layer caching
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "-r requirements.txt" in containerfile
|
||||
|
||||
|
||||
def test_generate_containerfile_creates_user(installer):
|
||||
"""Test Containerfile creates non-root user."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should create user for security
|
||||
assert "useradd" in containerfile.lower() or "USER" in containerfile
|
||||
|
||||
|
||||
def test_create_build_context_creates_directory(installer):
|
||||
"""Test that build context directory is created."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
assert context_path.exists()
|
||||
assert context_path.is_dir()
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_contains_containerfile(installer):
|
||||
"""Test that build context contains Containerfile."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
containerfile_path = context_path / "Containerfile"
|
||||
assert containerfile_path.exists()
|
||||
|
||||
content = containerfile_path.read_text()
|
||||
assert f"FROM {base_image}" in content
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_contains_requirements(installer):
|
||||
"""Test that build context contains requirements.txt."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy>=1.24.0", "pandas"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
requirements_path = context_path / "requirements.txt"
|
||||
assert requirements_path.exists()
|
||||
|
||||
content = requirements_path.read_text()
|
||||
assert "numpy>=1.24.0" in content
|
||||
assert "pandas" in content
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_create_build_context_returns_temp_directory(installer):
|
||||
"""Test that build context is in temp directory."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(base_image, packages)
|
||||
|
||||
try:
|
||||
# Should be in system temp directory
|
||||
temp_dir = Path(tempfile.gettempdir())
|
||||
assert temp_dir in context_path.parents
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_get_cache_volume_mount_returns_dict(installer):
|
||||
"""Test cache volume mount returns proper dict."""
|
||||
mount_config = installer.get_cache_volume_mount()
|
||||
|
||||
assert isinstance(mount_config, dict)
|
||||
assert "bind" in mount_config
|
||||
assert "mode" in mount_config
|
||||
assert mount_config["mode"] == "rw"
|
||||
|
||||
|
||||
def test_get_cache_volume_mount_includes_cache_path(installer, cache_path):
|
||||
"""Test cache volume mount includes cache path."""
|
||||
mount_config = installer.get_cache_volume_mount()
|
||||
|
||||
# The bind target should reference UV cache location
|
||||
assert "bind" in mount_config
|
||||
assert "/cache" in mount_config["bind"] or "uv" in mount_config["bind"].lower()
|
||||
|
||||
|
||||
def test_generate_containerfile_security_practices(installer):
|
||||
"""Test Containerfile follows security best practices."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should not run as root
|
||||
assert "USER" in containerfile
|
||||
|
||||
# Should set working directory
|
||||
assert "WORKDIR" in containerfile
|
||||
|
||||
|
||||
def test_generate_requirements_preserves_version_specs(installer):
|
||||
"""Test that version specifiers are preserved exactly."""
|
||||
packages = [
|
||||
"numpy>=1.24.0,<2.0.0",
|
||||
"pandas==2.0.0",
|
||||
"requests~=2.28.0",
|
||||
"scipy!=1.10.0"
|
||||
]
|
||||
|
||||
requirements = installer.generate_requirements(packages)
|
||||
|
||||
for package in packages:
|
||||
assert package in requirements
|
||||
|
||||
|
||||
def test_generate_containerfile_with_extras(installer):
|
||||
"""Test Containerfile works with package extras."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["requests[security]>=2.28.0", "pandas[excel]"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should handle extras in requirements
|
||||
assert "COPY" in containerfile and "requirements.txt" in containerfile
|
||||
assert "RUN uv pip install" in containerfile
|
||||
|
||||
|
||||
def test_create_build_context_with_custom_python_version(installer):
|
||||
"""Test build context creation with custom Python version."""
|
||||
base_image = "docker.io/python:3.12-slim"
|
||||
packages = ["numpy"]
|
||||
|
||||
context_path = installer.create_build_context(
|
||||
base_image, packages, python_version="3.12"
|
||||
)
|
||||
|
||||
try:
|
||||
containerfile = (context_path / "Containerfile").read_text()
|
||||
assert "3.12" in containerfile or "python:3.12" in containerfile
|
||||
finally:
|
||||
if context_path.exists():
|
||||
shutil.rmtree(context_path)
|
||||
|
||||
|
||||
def test_generate_containerfile_optimizes_layer_caching(installer):
|
||||
"""Test that Containerfile structure optimizes Docker layer caching."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = ["numpy", "pandas", "scipy"]
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
lines = containerfile.split('\n')
|
||||
|
||||
# UV install should come before requirements copy
|
||||
uv_install_idx = None
|
||||
requirements_idx = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if 'uv' in line.lower() and ('pip install' in line.lower() or 'ADD' in line):
|
||||
uv_install_idx = i
|
||||
if 'COPY' in line and 'requirements.txt' in line:
|
||||
requirements_idx = i
|
||||
|
||||
# UV installation should be cached separately
|
||||
assert uv_install_idx is not None
|
||||
# Requirements copy should happen for cache busting
|
||||
assert requirements_idx is not None
|
||||
|
||||
|
||||
def test_empty_packages_list_creates_valid_containerfile(installer):
|
||||
"""Test that empty package list still creates valid Containerfile."""
|
||||
base_image = "docker.io/python:3.11-slim"
|
||||
packages = []
|
||||
|
||||
containerfile = installer.generate_containerfile(base_image, packages)
|
||||
|
||||
# Should still have base structure
|
||||
assert f"FROM {base_image}" in containerfile
|
||||
assert "WORKDIR" in containerfile
|
||||
Loading…
Add table
Add a link
Reference in a new issue