mcp-forge/src/mcp_forge/builder/environment_builder.py
2026-02-07 07:45:57 +01:00

315 lines
9.9 KiB
Python

"""Environment builder orchestration with security validation."""
import re
import shutil
import threading
from typing import List, Optional, Dict, Set
from pathlib import Path
from datetime import datetime, timedelta
from mcp_forge.config.schema import EnvironmentBuilderConfig
from mcp_forge.podman.client import PodmanClient
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
from mcp_forge.builder.package_validator import PackageValidator
from mcp_forge.builder.uv_installer import UVInstaller
from mcp_forge.builder.image_builder import ImageBuilder, BuildResult
class BuildRateLimiter:
"""Rate limiter for build requests."""
def __init__(self, max_requests: int, period_seconds: int):
"""
Initialize rate limiter.
Args:
max_requests: Maximum requests per period
period_seconds: Period length in seconds
"""
self.max_requests = max_requests
self.period_seconds = period_seconds
self.requests: Dict[str, List[datetime]] = {}
self.lock = threading.Lock()
def check_rate_limit(self, user_id: str) -> None:
"""
Check if user is within rate limit.
Args:
user_id: User identifier
Raises:
RuntimeError: If rate limit exceeded
"""
with self.lock:
now = datetime.now()
# Initialize user's request list if needed
if user_id not in self.requests:
self.requests[user_id] = []
# Clean up old requests
self._cleanup_old_requests(user_id, now)
# Check if at limit
if len(self.requests[user_id]) >= self.max_requests:
raise RuntimeError(
f"Rate limit exceeded: {self.max_requests} requests "
f"per {self.period_seconds} seconds"
)
# Record this request
self.requests[user_id].append(now)
def _cleanup_old_requests(self, user_id: str, now: datetime) -> None:
"""
Remove requests older than period.
Args:
user_id: User identifier
now: Current time
"""
cutoff = now - timedelta(seconds=self.period_seconds)
self.requests[user_id] = [
req_time for req_time in self.requests[user_id]
if req_time > cutoff
]
class EnvironmentBuilder:
"""Builds custom Python environments with security validation."""
def __init__(
self,
config: EnvironmentBuilderConfig,
podman_client: PodmanClient,
audit_logger: AuditLogger
):
"""
Initialize environment builder.
Args:
config: Environment builder configuration
podman_client: Podman API client
audit_logger: Audit logging instance
"""
self.config = config
self.podman = podman_client
self.audit_logger = audit_logger
# Initialize sub-components
self.package_validator = PackageValidator(
config.package_validation
)
self.uv_installer = UVInstaller(config.uv_cache_path)
self.image_builder = ImageBuilder(
podman_client, config, audit_logger
)
# Rate limiting and concurrency control
self.rate_limiter = BuildRateLimiter(
max_requests=config.build_rate_limit['requests'],
period_seconds=config.build_rate_limit['period']
)
self.active_builds: Set[str] = set()
self.active_builds_lock = threading.Lock()
def build_custom_environment(
self,
name: str,
packages: List[str],
base_image: str = "python:3.11-slim",
python_version: str = "3.11",
description: str = "",
user_id: str = "default"
) -> BuildResult:
"""
Build custom environment with packages.
Process:
1. Check rate limit
2. Check concurrent builds limit
3. Validate environment name
4. Validate package count
5. Validate package names (allowlist/blocklist)
6. Generate build context with UV
7. Build image
8. Cleanup build context
Args:
name: Environment name (alphanumeric + hyphens)
packages: List of package specifications
base_image: Base image to build from
python_version: Python version
description: Optional description
user_id: User ID for rate limiting
Returns:
BuildResult
Raises:
ValueError: If validation fails
SecurityError: If security check fails
RuntimeError: If rate limit or concurrency exceeded
"""
# Check rate limit
self.rate_limiter.check_rate_limit(user_id)
# Check concurrent builds
self._check_concurrent_builds()
# Validate environment name
if not re.match(r'^[a-zA-Z0-9-]+$', name):
raise ValueError(
f"Environment name '{name}' must contain only alphanumeric characters and hyphens"
)
# Validate package count
if len(packages) > self.config.max_packages:
raise ValueError(
f"Package count {len(packages)} exceeds maximum {self.config.max_packages}"
)
# Log build start
self.audit_logger.log(
event_type=AuditEventType.BUILD_REQUEST,
severity=AuditSeverity.INFO,
message=f"Building environment: {name}",
details={
"packages": packages,
"base_image": base_image,
"user_id": user_id
}
)
build_context = None
try:
# Register build as active
self._register_build_start(name)
# Validate packages
self.package_validator.validate_packages(packages)
# Generate build context
build_context = self.uv_installer.create_build_context(
packages=packages,
base_image=base_image,
python_version=python_version
)
# Build image
result = self.image_builder.build_image(
name=name,
build_context=build_context,
base_image=base_image,
packages=packages
)
# Log completion
self.audit_logger.log(
event_type=AuditEventType.BUILD_COMPLETE,
severity=AuditSeverity.INFO,
message=f"Environment built: {name}",
details={
"image_id": result.image_id,
"build_time": result.build_time,
"user_id": user_id
}
)
return result
finally:
# Always cleanup
self._register_build_complete(name)
if build_context and build_context.exists():
shutil.rmtree(build_context, ignore_errors=True)
def build_from_template(
self,
template_name: str,
additional_packages: Optional[List[str]] = None,
name: Optional[str] = None,
user_id: str = "default"
) -> BuildResult:
"""
Build environment from template.
Expands template packages and adds additional packages.
Args:
template_name: Name of template to use
additional_packages: Optional additional packages
name: Optional custom name (uses template name if not provided)
user_id: User ID for rate limiting
Returns:
BuildResult
Raises:
ValueError: If template not found
"""
# Validate template exists
if template_name not in self.config.templates:
raise ValueError(f"Template '{template_name}' not found")
template = self.config.templates[template_name]
# Combine template and additional packages
packages = template["packages"].copy()
if additional_packages:
packages.extend(additional_packages)
# Use template name if no custom name provided
if name is None:
name = template_name
# Build with combined package list
return self.build_custom_environment(
name=name,
packages=packages,
description=template.get("description", ""),
user_id=user_id
)
def list_templates(self) -> Dict[str, dict]:
"""
List available templates.
Returns:
Dictionary of template name to template metadata
"""
return self.config.templates
def _check_concurrent_builds(self) -> None:
"""
Check concurrent builds limit.
Raises:
RuntimeError: If at max concurrent builds
"""
with self.active_builds_lock:
if len(self.active_builds) >= self.config.max_concurrent_builds:
raise RuntimeError(
f"Maximum concurrent builds ({self.config.max_concurrent_builds}) reached"
)
def _register_build_start(self, name: str) -> None:
"""
Register build as started.
Args:
name: Environment name
"""
with self.active_builds_lock:
self.active_builds.add(name)
def _register_build_complete(self, name: str) -> None:
"""
Register build as completed.
Args:
name: Environment name
"""
with self.active_builds_lock:
self.active_builds.discard(name)