initial commit after one day coding agent session

This commit is contained in:
Hans Aschauer 2026-02-07 07:45:57 +01:00
commit 372af75b90
88 changed files with 22694 additions and 0 deletions

View file

@ -0,0 +1,315 @@
"""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)