initial commit after one day coding agent session
This commit is contained in:
commit
372af75b90
88 changed files with 22694 additions and 0 deletions
17
src/mcp_forge/builder/__init__.py
Normal file
17
src/mcp_forge/builder/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Custom environment builder components."""
|
||||
|
||||
from .package_validator import PackageValidator, SecurityError, ApprovalRequiredError
|
||||
from .uv_installer import UVInstaller
|
||||
from .image_builder import ImageBuilder, BuildResult
|
||||
from .environment_builder import EnvironmentBuilder, BuildRateLimiter
|
||||
|
||||
__all__ = [
|
||||
"PackageValidator",
|
||||
"SecurityError",
|
||||
"ApprovalRequiredError",
|
||||
"UVInstaller",
|
||||
"ImageBuilder",
|
||||
"BuildResult",
|
||||
"EnvironmentBuilder",
|
||||
"BuildRateLimiter",
|
||||
]
|
||||
315
src/mcp_forge/builder/environment_builder.py
Normal file
315
src/mcp_forge/builder/environment_builder.py
Normal 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)
|
||||
315
src/mcp_forge/builder/image_builder.py
Normal file
315
src/mcp_forge/builder/image_builder.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""Container image builder with security validation."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mcp_forge.podman.client import PodmanClient
|
||||
from mcp_forge.config.schema import EnvironmentBuilderConfig
|
||||
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||
from mcp_forge.security.resource_limits import parse_memory_string
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildResult:
|
||||
"""Result of image build."""
|
||||
success: bool
|
||||
image_name: str
|
||||
image_id: str
|
||||
build_time: float
|
||||
size_bytes: int
|
||||
cache_hit: bool
|
||||
installed_packages: List[str]
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"success": self.success,
|
||||
"image_name": self.image_name,
|
||||
"image_id": self.image_id,
|
||||
"build_time": self.build_time,
|
||||
"size_bytes": self.size_bytes,
|
||||
"cache_hit": self.cache_hit,
|
||||
"installed_packages": self.installed_packages,
|
||||
"error": self.error
|
||||
}
|
||||
|
||||
|
||||
class ImageBuilder:
|
||||
"""Builds container images with security validation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
podman_client: PodmanClient,
|
||||
config: EnvironmentBuilderConfig,
|
||||
audit_logger: AuditLogger
|
||||
):
|
||||
"""
|
||||
Initialize image builder.
|
||||
|
||||
Args:
|
||||
podman_client: Podman API client
|
||||
config: Environment builder configuration
|
||||
audit_logger: Audit logging instance
|
||||
"""
|
||||
self.podman = podman_client
|
||||
self.config = config
|
||||
self.audit_logger = audit_logger
|
||||
|
||||
def build_image(
|
||||
self,
|
||||
name: str,
|
||||
build_context: Path,
|
||||
base_image: str,
|
||||
packages: List[str],
|
||||
timeout: Optional[int] = None
|
||||
) -> BuildResult:
|
||||
"""
|
||||
Build container image from build context.
|
||||
|
||||
Process:
|
||||
1. Validate build context
|
||||
2. Generate image tag
|
||||
3. Build image with Podman
|
||||
4. Validate image size
|
||||
5. Extract installed packages
|
||||
6. Cleanup build artifacts
|
||||
|
||||
Args:
|
||||
name: Environment name (user-provided)
|
||||
build_context: Path to build context directory
|
||||
base_image: Base image to build from
|
||||
packages: List of packages being installed
|
||||
timeout: Build timeout (uses config default if None)
|
||||
|
||||
Returns:
|
||||
BuildResult
|
||||
|
||||
Raises:
|
||||
ValueError: If timeout exceeds max or validation fails
|
||||
RuntimeError: If build fails
|
||||
"""
|
||||
# Validate build context
|
||||
self._validate_build_context(build_context)
|
||||
|
||||
# Use default timeout if not specified
|
||||
timeout = timeout if timeout is not None else self.config.build_timeout
|
||||
|
||||
# Validate timeout against maximum
|
||||
if timeout > self.config.max_build_timeout:
|
||||
raise ValueError(
|
||||
f"Timeout {timeout} exceeds maximum {self.config.max_build_timeout}"
|
||||
)
|
||||
|
||||
# Generate image tag
|
||||
tag = self.generate_tag(name)
|
||||
|
||||
# Calculate cache hash
|
||||
cache_hash = self.calculate_cache_hash(packages)
|
||||
|
||||
# Log build start
|
||||
self.audit_logger.log(
|
||||
event_type=AuditEventType.IMAGE_BUILD_START,
|
||||
severity=AuditSeverity.INFO,
|
||||
message=f"Building image: {tag}",
|
||||
details={
|
||||
"base_image": base_image,
|
||||
"packages": packages,
|
||||
"cache_hash": cache_hash
|
||||
}
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Build image with Podman
|
||||
image, build_logs = self.podman.images.build(
|
||||
path=str(build_context),
|
||||
tag=tag,
|
||||
timeout=timeout,
|
||||
rm=True, # Remove intermediate containers
|
||||
pull=False # Don't pull base image (assume it exists)
|
||||
)
|
||||
|
||||
build_time = time.time() - start_time
|
||||
|
||||
# Validate image size
|
||||
size_bytes = self.validate_image_size(image.id)
|
||||
|
||||
# Extract installed packages
|
||||
installed_packages = self.extract_installed_packages(image.id)
|
||||
|
||||
# Log success
|
||||
self.audit_logger.log(
|
||||
event_type=AuditEventType.IMAGE_BUILD_SUCCESS,
|
||||
severity=AuditSeverity.INFO,
|
||||
message=f"Image built successfully: {tag}",
|
||||
details={
|
||||
"image_id": image.id,
|
||||
"build_time": build_time,
|
||||
"size_bytes": size_bytes,
|
||||
"installed_packages": len(installed_packages)
|
||||
}
|
||||
)
|
||||
|
||||
return BuildResult(
|
||||
success=True,
|
||||
image_name=tag,
|
||||
image_id=image.id,
|
||||
build_time=build_time,
|
||||
size_bytes=size_bytes,
|
||||
cache_hit=False, # TODO: implement cache checking
|
||||
installed_packages=installed_packages
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
build_time = time.time() - start_time
|
||||
|
||||
# Log failure
|
||||
self.audit_logger.log(
|
||||
event_type=AuditEventType.IMAGE_BUILD_FAILURE,
|
||||
severity=AuditSeverity.ERROR,
|
||||
message=f"Image build failed: {tag}",
|
||||
error=str(e),
|
||||
details={"build_time": build_time}
|
||||
)
|
||||
|
||||
raise RuntimeError(f"Build failed: {e}") from e
|
||||
|
||||
def generate_tag(self, name: str) -> str:
|
||||
"""
|
||||
Generate image tag.
|
||||
|
||||
Format: mcp-forge/custom:{name}
|
||||
Validates name is alphanumeric + hyphens only.
|
||||
|
||||
Args:
|
||||
name: Environment name
|
||||
|
||||
Returns:
|
||||
Full image tag
|
||||
|
||||
Raises:
|
||||
ValueError: If name contains invalid characters
|
||||
"""
|
||||
# Validate name (alphanumeric + hyphens only)
|
||||
if not re.match(r'^[a-zA-Z0-9-]+$', name):
|
||||
raise ValueError(
|
||||
f"Environment name '{name}' must contain only alphanumeric characters and hyphens"
|
||||
)
|
||||
|
||||
return f"mcp-forge/custom:{name}"
|
||||
|
||||
def validate_image_size(self, image_id: str) -> int:
|
||||
"""
|
||||
Validate image size against maximum.
|
||||
|
||||
Args:
|
||||
image_id: Image ID to validate
|
||||
|
||||
Returns:
|
||||
Size in bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If image exceeds max size
|
||||
"""
|
||||
image = self.podman.images.get(image_id)
|
||||
size_bytes = image.attrs.get("Size", 0)
|
||||
|
||||
max_size_bytes = parse_memory_string(self.config.max_image_size)
|
||||
|
||||
if size_bytes > max_size_bytes:
|
||||
raise ValueError(
|
||||
f"Image size {size_bytes} bytes exceeds maximum {max_size_bytes} bytes"
|
||||
)
|
||||
|
||||
return size_bytes
|
||||
|
||||
def extract_installed_packages(self, image_id: str) -> List[str]:
|
||||
"""
|
||||
Extract list of installed packages from image.
|
||||
|
||||
Runs: pip list --format=json in container
|
||||
|
||||
Args:
|
||||
image_id: Image ID to inspect
|
||||
|
||||
Returns:
|
||||
List of package specifications (name==version)
|
||||
"""
|
||||
try:
|
||||
# Run pip list in container
|
||||
container = self.podman.containers.run(
|
||||
image=image_id,
|
||||
command=["pip", "list", "--format=json"],
|
||||
remove=False,
|
||||
detach=False
|
||||
)
|
||||
|
||||
exit_code, output = container.exec_run(
|
||||
["pip", "list", "--format=json"]
|
||||
)
|
||||
|
||||
if exit_code != 0:
|
||||
return []
|
||||
|
||||
# Parse JSON output
|
||||
packages_data = json.loads(output.decode('utf-8'))
|
||||
|
||||
# Format as name==version
|
||||
packages = [
|
||||
f"{pkg['name']}=={pkg['version']}"
|
||||
for pkg in packages_data
|
||||
]
|
||||
|
||||
return packages
|
||||
|
||||
except Exception:
|
||||
# Return empty list on error
|
||||
return []
|
||||
|
||||
def calculate_cache_hash(self, packages: List[str]) -> str:
|
||||
"""
|
||||
Calculate hash of package list for cache key.
|
||||
|
||||
Hash is order-independent (sorts packages first).
|
||||
|
||||
Args:
|
||||
packages: List of package specifications
|
||||
|
||||
Returns:
|
||||
SHA256 hash hex string
|
||||
"""
|
||||
# Sort packages for order-independent hash
|
||||
sorted_packages = sorted(packages)
|
||||
|
||||
# Join and hash
|
||||
packages_str = '\n'.join(sorted_packages)
|
||||
hash_obj = hashlib.sha256(packages_str.encode('utf-8'))
|
||||
|
||||
return hash_obj.hexdigest()
|
||||
|
||||
def _validate_build_context(self, build_context: Path) -> None:
|
||||
"""
|
||||
Validate build context exists and contains Containerfile.
|
||||
|
||||
Args:
|
||||
build_context: Path to build context
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails
|
||||
"""
|
||||
if not build_context.exists():
|
||||
raise ValueError(f"Build context does not exist: {build_context}")
|
||||
|
||||
if not build_context.is_dir():
|
||||
raise ValueError(f"Build context is not a directory: {build_context}")
|
||||
|
||||
containerfile = build_context / "Containerfile"
|
||||
if not containerfile.exists():
|
||||
raise ValueError(f"Containerfile not found in build context: {build_context}")
|
||||
199
src/mcp_forge/builder/package_validator.py
Normal file
199
src/mcp_forge/builder/package_validator.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Package validation against security policies."""
|
||||
|
||||
import re
|
||||
from typing import List, Set, Optional, Pattern
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_forge.config.schema import PackageValidationConfig
|
||||
|
||||
|
||||
class SecurityError(Exception):
|
||||
"""Raised when package is blocked by security policy."""
|
||||
pass
|
||||
|
||||
|
||||
class ApprovalRequiredError(Exception):
|
||||
"""Raised when package requires manual approval."""
|
||||
pass
|
||||
|
||||
|
||||
class PackageValidator:
|
||||
"""Validates package names against security policy."""
|
||||
|
||||
def __init__(self, config: PackageValidationConfig):
|
||||
"""
|
||||
Initialize package validator.
|
||||
|
||||
Args:
|
||||
config: Package validation configuration
|
||||
"""
|
||||
self.config = config
|
||||
self.allowlist: Set[str] = self._load_allowlist()
|
||||
self.blocklist: Set[str] = self._load_blocklist()
|
||||
self.approval_patterns: List[Pattern] = self._compile_patterns()
|
||||
|
||||
def validate_packages(
|
||||
self,
|
||||
packages: List[str],
|
||||
max_packages: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Validate list of package specifications.
|
||||
|
||||
Args:
|
||||
packages: List of package specs (e.g., ["numpy>=1.24", "pandas"])
|
||||
max_packages: Maximum number of packages allowed
|
||||
|
||||
Raises:
|
||||
ValueError: If too many packages
|
||||
SecurityError: If package is blocklisted
|
||||
ApprovalRequiredError: If package requires approval
|
||||
"""
|
||||
# Check package count limit
|
||||
if max_packages is not None and len(packages) > max_packages:
|
||||
raise ValueError(
|
||||
f"Maximum {max_packages} packages allowed, got {len(packages)}"
|
||||
)
|
||||
|
||||
# Validate each package
|
||||
for package_spec in packages:
|
||||
self.validate_package(package_spec)
|
||||
|
||||
def validate_package(self, package_spec: str) -> None:
|
||||
"""
|
||||
Validate single package specification.
|
||||
|
||||
Extracts package name from spec (handles >=, ==, <=, etc.)
|
||||
Checks against blocklist, allowlist, and approval patterns.
|
||||
|
||||
Args:
|
||||
package_spec: Package specification (e.g., "numpy>=1.24.0")
|
||||
|
||||
Raises:
|
||||
SecurityError: If package is blocklisted or not in allowlist
|
||||
ApprovalRequiredError: If package requires manual approval
|
||||
"""
|
||||
# Extract clean package name
|
||||
package_name = self.extract_package_name(package_spec)
|
||||
|
||||
# Check blocklist first (highest priority)
|
||||
if package_name in self.blocklist:
|
||||
raise SecurityError(f"Package '{package_name}' is blocklisted")
|
||||
|
||||
# Check approval patterns
|
||||
for pattern in self.approval_patterns:
|
||||
if pattern.match(package_name):
|
||||
raise ApprovalRequiredError(
|
||||
f"Package '{package_name}' requires manual approval"
|
||||
)
|
||||
|
||||
# Check allowlist if enabled
|
||||
if self.config.use_allowlist:
|
||||
if package_name not in self.allowlist:
|
||||
raise SecurityError(
|
||||
f"Package '{package_name}' is not in allowlist"
|
||||
)
|
||||
|
||||
def extract_package_name(self, package_spec: str) -> str:
|
||||
"""
|
||||
Extract package name from specification.
|
||||
|
||||
Handles version specifiers, extras, and whitespace.
|
||||
|
||||
Examples:
|
||||
"numpy>=1.24.0" → "numpy"
|
||||
"requests==2.28.0" → "requests"
|
||||
"pandas[excel]" → "pandas"
|
||||
" numpy " → "numpy"
|
||||
|
||||
Args:
|
||||
package_spec: Package specification string
|
||||
|
||||
Returns:
|
||||
Clean package name
|
||||
"""
|
||||
# Remove leading/trailing whitespace
|
||||
spec = package_spec.strip()
|
||||
|
||||
# Remove version specifiers (>=, ==, <=, ~=, !=, <, >)
|
||||
# Pattern matches: package-name[extras]>=version,<version
|
||||
# We want to extract just the package-name part
|
||||
|
||||
# First remove extras like [security] or [excel,sql]
|
||||
if '[' in spec:
|
||||
spec = spec.split('[')[0]
|
||||
|
||||
# Then remove version specifiers
|
||||
# Match any of: >= == <= ~= != < > ,
|
||||
spec = re.split(r'[><=!~,]', spec)[0]
|
||||
|
||||
return spec.strip()
|
||||
|
||||
def _load_allowlist(self) -> Set[str]:
|
||||
"""
|
||||
Load allowlist from file.
|
||||
|
||||
Returns:
|
||||
Set of allowed package names
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If allowlist file doesn't exist when use_allowlist is True
|
||||
"""
|
||||
if not self.config.use_allowlist or not self.config.allowlist_path:
|
||||
return set()
|
||||
|
||||
path = Path(self.config.allowlist_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Allowlist file not found: {path}")
|
||||
|
||||
allowlist = set()
|
||||
with open(path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip comments and empty lines
|
||||
if line and not line.startswith('#'):
|
||||
allowlist.add(line)
|
||||
|
||||
return allowlist
|
||||
|
||||
def _load_blocklist(self) -> Set[str]:
|
||||
"""
|
||||
Load blocklist from file.
|
||||
|
||||
Returns:
|
||||
Set of blocked package names
|
||||
"""
|
||||
if not self.config.blocklist_path:
|
||||
return set()
|
||||
|
||||
path = Path(self.config.blocklist_path)
|
||||
if not path.exists():
|
||||
return set()
|
||||
|
||||
blocklist = set()
|
||||
with open(path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip comments and empty lines
|
||||
if line and not line.startswith('#'):
|
||||
blocklist.add(line)
|
||||
|
||||
return blocklist
|
||||
|
||||
def _compile_patterns(self) -> List[Pattern]:
|
||||
"""
|
||||
Compile approval requirement patterns.
|
||||
|
||||
Returns:
|
||||
List of compiled regex patterns
|
||||
"""
|
||||
patterns = []
|
||||
for pattern_str in self.config.require_approval_patterns:
|
||||
try:
|
||||
pattern = re.compile(pattern_str)
|
||||
patterns.append(pattern)
|
||||
except re.error:
|
||||
# Log warning but continue
|
||||
pass
|
||||
|
||||
return patterns
|
||||
158
src/mcp_forge/builder/uv_installer.py
Normal file
158
src/mcp_forge/builder/uv_installer.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""UV-based package installer for custom environments."""
|
||||
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class UVInstaller:
|
||||
"""Manages UV-based package installation in containers."""
|
||||
|
||||
def __init__(self, cache_path: Path):
|
||||
"""
|
||||
Initialize UV installer.
|
||||
|
||||
Args:
|
||||
cache_path: Path to UV cache directory
|
||||
"""
|
||||
self.cache_path = Path(cache_path)
|
||||
self._ensure_cache_dir()
|
||||
|
||||
def generate_requirements(self, packages: List[str]) -> str:
|
||||
"""
|
||||
Generate requirements.txt content.
|
||||
|
||||
One package per line with version specifiers preserved.
|
||||
|
||||
Args:
|
||||
packages: List of package specifications
|
||||
|
||||
Returns:
|
||||
requirements.txt content
|
||||
"""
|
||||
if not packages:
|
||||
return ""
|
||||
|
||||
return '\n'.join(packages)
|
||||
|
||||
def generate_containerfile(
|
||||
self,
|
||||
base_image: str,
|
||||
packages: List[str],
|
||||
python_version: str = "3.11"
|
||||
) -> str:
|
||||
"""
|
||||
Generate Containerfile for building custom environment.
|
||||
|
||||
Containerfile structure optimizes layer caching:
|
||||
1. Base image
|
||||
2. Install UV (cached layer)
|
||||
3. Create non-root user
|
||||
4. Copy requirements.txt (cache-friendly)
|
||||
5. Install packages with UV
|
||||
6. Set working directory
|
||||
|
||||
Args:
|
||||
base_image: Base container image
|
||||
packages: List of package specifications
|
||||
python_version: Python version (for reference)
|
||||
|
||||
Returns:
|
||||
Containerfile content
|
||||
"""
|
||||
has_packages = bool(packages)
|
||||
|
||||
containerfile = f"""FROM {base_image}
|
||||
|
||||
# Install UV for fast package installation
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd -m -u 1000 -s /bin/bash forge && \\
|
||||
mkdir -p /home/forge/.cache/uv && \\
|
||||
chown -R forge:forge /home/forge
|
||||
|
||||
# Switch to non-root user
|
||||
USER forge
|
||||
WORKDIR /home/forge
|
||||
|
||||
# Copy requirements for layer caching
|
||||
COPY --chown=forge:forge requirements.txt /home/forge/requirements.txt
|
||||
|
||||
"""
|
||||
|
||||
if has_packages:
|
||||
containerfile += """# Install packages with UV
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
|
||||
"""
|
||||
|
||||
containerfile += """# Set working directory
|
||||
WORKDIR /home/forge/workspace
|
||||
|
||||
# Default command
|
||||
CMD ["/bin/bash"]
|
||||
"""
|
||||
|
||||
return containerfile
|
||||
|
||||
def create_build_context(
|
||||
self,
|
||||
base_image: str,
|
||||
packages: List[str],
|
||||
python_version: str = "3.11"
|
||||
) -> Path:
|
||||
"""
|
||||
Create temporary build context directory.
|
||||
|
||||
Contains:
|
||||
- Containerfile
|
||||
- requirements.txt
|
||||
|
||||
Args:
|
||||
base_image: Base container image
|
||||
packages: List of package specifications
|
||||
python_version: Python version
|
||||
|
||||
Returns:
|
||||
Path to build context directory (caller must cleanup)
|
||||
"""
|
||||
# Create temporary directory
|
||||
context_dir = Path(tempfile.mkdtemp(prefix="mcp-forge-build-"))
|
||||
|
||||
try:
|
||||
# Generate Containerfile
|
||||
containerfile_content = self.generate_containerfile(
|
||||
base_image, packages, python_version
|
||||
)
|
||||
containerfile_path = context_dir / "Containerfile"
|
||||
containerfile_path.write_text(containerfile_content)
|
||||
|
||||
# Generate requirements.txt
|
||||
requirements_content = self.generate_requirements(packages)
|
||||
requirements_path = context_dir / "requirements.txt"
|
||||
requirements_path.write_text(requirements_content)
|
||||
|
||||
return context_dir
|
||||
|
||||
except Exception:
|
||||
# Cleanup on error
|
||||
shutil.rmtree(context_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
def get_cache_volume_mount(self) -> dict:
|
||||
"""
|
||||
Get volume mount configuration for UV cache.
|
||||
|
||||
Returns:
|
||||
Volume mount dict for Podman
|
||||
"""
|
||||
return {
|
||||
"bind": "/home/forge/.cache/uv",
|
||||
"mode": "rw"
|
||||
}
|
||||
|
||||
def _ensure_cache_dir(self) -> None:
|
||||
"""Ensure UV cache directory exists."""
|
||||
self.cache_path.mkdir(parents=True, exist_ok=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue