feat: Implement session management and secure container lifecycle management
- Added session management for stateful execution in `sessions.py`, including session creation, state documentation, and cleanup of idle sessions. - Introduced `SecureContainerManager` in `containers.py` for managing container lifecycle with security enforcement, including creation, starting, stopping, and removal of containers. - Updated server initialization to use the new session manager. - Enhanced container configuration to skip resource limits for very high values, indicating no enforcement. - Improved logging capabilities for container operations in the audit logger. - Refactored Jupyter backend to integrate with the new session management and resource limits handling.
This commit is contained in:
parent
9ceeaa1eda
commit
7d9efc5a38
10 changed files with 3384 additions and 26 deletions
1448
docs/architecture2.md
Normal file
1448
docs/architecture2.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -42,28 +42,40 @@ class SimpleBackend:
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
# Create default resource limits from config
|
# Create default resource limits from config
|
||||||
self.default_limits = ResourceLimits(
|
# When not enforcing limits, use very high values to effectively disable
|
||||||
memory=config.execution.default_memory,
|
if config.security.enforce_resource_limits:
|
||||||
storage="10g", # Default storage limit
|
self.default_limits = ResourceLimits(
|
||||||
cpu_quota=config.execution.default_cpu_quota,
|
memory=config.execution.default_memory,
|
||||||
timeout=config.execution.default_timeout
|
storage="10g",
|
||||||
)
|
cpu_quota=config.execution.default_cpu_quota,
|
||||||
|
timeout=config.execution.default_timeout
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No resource enforcement - use very high limits (effectively unlimited)
|
||||||
|
self.default_limits = ResourceLimits(
|
||||||
|
memory="16g", # Very high memory limit
|
||||||
|
storage="100g", # Very high storage limit
|
||||||
|
cpu_quota=1000000, # Effectively unlimited CPU
|
||||||
|
timeout=config.execution.default_timeout
|
||||||
|
)
|
||||||
|
|
||||||
# Create executor with default image
|
# Create executor with default image (prefer python_3_12)
|
||||||
self.executor = CodeExecutor(
|
self.executor = CodeExecutor(
|
||||||
container_manager=container_manager,
|
container_manager=container_manager,
|
||||||
image=config.images.python,
|
image=config.images.python_3_12,
|
||||||
resource_limits=self.default_limits
|
resource_limits=self.default_limits
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"SimpleBackend initialized with image={config.images.python}")
|
logger.debug(f"SimpleBackend initialized with image={config.images.python_3_12}")
|
||||||
|
|
||||||
def execute(
|
def execute(
|
||||||
self,
|
self,
|
||||||
code: str,
|
code: str,
|
||||||
timeout: Optional[int] = None,
|
timeout: Optional[int] = None,
|
||||||
memory: Optional[str] = None,
|
memory: Optional[str] = None,
|
||||||
cpu_quota: Optional[int] = None
|
cpu_quota: Optional[int] = None,
|
||||||
|
injection_code: Optional[str] = None,
|
||||||
|
bridge_socket_path: Optional[str] = None
|
||||||
) -> ExecutionResult:
|
) -> ExecutionResult:
|
||||||
"""
|
"""
|
||||||
Execute Python code in isolated container.
|
Execute Python code in isolated container.
|
||||||
|
|
@ -73,6 +85,8 @@ class SimpleBackend:
|
||||||
timeout: Optional timeout override (seconds)
|
timeout: Optional timeout override (seconds)
|
||||||
memory: Optional memory limit override (e.g., "512m")
|
memory: Optional memory limit override (e.g., "512m")
|
||||||
cpu_quota: Optional CPU quota override
|
cpu_quota: Optional CPU quota override
|
||||||
|
injection_code: Optional code to inject before user code (for MCP tools)
|
||||||
|
bridge_socket_path: Optional path to MCP bridge socket
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ExecutionResult with stdout, stderr, result, etc.
|
ExecutionResult with stdout, stderr, result, etc.
|
||||||
|
|
@ -88,10 +102,20 @@ class SimpleBackend:
|
||||||
# Create temporary executor with custom limits
|
# Create temporary executor with custom limits
|
||||||
executor = CodeExecutor(
|
executor = CodeExecutor(
|
||||||
container_manager=self.container_manager,
|
container_manager=self.container_manager,
|
||||||
image=self.config.images.python,
|
image=self.config.images.python_3_12,
|
||||||
resource_limits=limits
|
resource_limits=limits
|
||||||
)
|
)
|
||||||
return executor.execute(code, timeout=timeout)
|
return executor.execute(
|
||||||
|
code=code,
|
||||||
# Use default executor
|
timeout=timeout,
|
||||||
return self.executor.execute(code, timeout=timeout)
|
injection_code=injection_code,
|
||||||
|
bridge_socket_path=bridge_socket_path
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Use default executor
|
||||||
|
return self.executor.execute(
|
||||||
|
code=code,
|
||||||
|
timeout=timeout,
|
||||||
|
injection_code=injection_code,
|
||||||
|
bridge_socket_path=bridge_socket_path
|
||||||
|
)
|
||||||
|
|
|
||||||
263
src/mcp_forge/execution/jupyter/backend.py
Normal file
263
src/mcp_forge/execution/jupyter/backend.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""Jupyter backend for stateful code execution."""
|
||||||
|
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from mcp_forge.config.schema import ForgeConfig
|
||||||
|
from mcp_forge.podman.containers import SecureContainerManager
|
||||||
|
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||||
|
from mcp_forge.security.resource_limits import ResourceLimits, parse_memory_string
|
||||||
|
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||||
|
from mcp_forge.execution.jupyter.kernel import JupyterKernelManager
|
||||||
|
from mcp_forge.execution.jupyter.sessions import SessionManager, SessionState, SessionError
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterBackend:
|
||||||
|
"""Stateful code execution backend using Jupyter kernels."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ForgeConfig,
|
||||||
|
container_manager: SecureContainerManager,
|
||||||
|
audit_logger: AuditLogger
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Jupyter backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Forge configuration
|
||||||
|
container_manager: Container lifecycle manager
|
||||||
|
audit_logger: Audit logging instance
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.container_manager = container_manager
|
||||||
|
self.audit_logger = audit_logger
|
||||||
|
|
||||||
|
# Initialize kernel manager
|
||||||
|
kernel_manager = JupyterKernelManager(
|
||||||
|
container_manager=container_manager,
|
||||||
|
image=config.images.jupyter,
|
||||||
|
resource_limits=self._default_resource_limits()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize session manager
|
||||||
|
self.session_manager = SessionManager(
|
||||||
|
config=config.sessions,
|
||||||
|
kernel_manager=kernel_manager,
|
||||||
|
audit_logger=audit_logger
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
session_id: str,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
memory: Optional[str] = None,
|
||||||
|
cpu_quota: Optional[int] = None,
|
||||||
|
custom_image: Optional[str] = None,
|
||||||
|
volumes: Optional[Dict[str, dict]] = None,
|
||||||
|
injection_code: Optional[str] = None,
|
||||||
|
bridge_socket_path: Optional[str] = None
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute code in stateful session.
|
||||||
|
|
||||||
|
Creates session if it doesn't exist, reuses existing session otherwise.
|
||||||
|
Session maintains namespace state across multiple executions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Python code to execute
|
||||||
|
session_id: Unique session identifier
|
||||||
|
timeout: Max execution time in seconds (uses config default if None)
|
||||||
|
memory: Memory limit string (uses config default if None)
|
||||||
|
cpu_quota: CPU quota (uses config default if None)
|
||||||
|
custom_image: Custom image name (uses config default if None)
|
||||||
|
volumes: Volume mounts dict
|
||||||
|
injection_code: Optional MCP tool injection code (executed once at session start)
|
||||||
|
bridge_socket_path: Optional path to MCP bridge socket for mounting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExecutionResult with execution output and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If limits exceed configured maximums
|
||||||
|
SessionError: If session operation fails
|
||||||
|
"""
|
||||||
|
# Use defaults from config if not specified
|
||||||
|
timeout = timeout if timeout is not None else self.config.execution.default_timeout
|
||||||
|
memory = memory if memory is not None else self.config.execution.default_memory
|
||||||
|
cpu_quota = cpu_quota if cpu_quota is not None else self.config.execution.default_cpu_quota
|
||||||
|
|
||||||
|
# Validate limits against maximums
|
||||||
|
self._validate_limits(timeout, memory, cpu_quota)
|
||||||
|
|
||||||
|
# Log execution (hash code, don't log actual content)
|
||||||
|
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.EXECUTION_REQUEST,
|
||||||
|
severity=AuditSeverity.INFO,
|
||||||
|
message="Stateful code execution requested",
|
||||||
|
session_id=session_id,
|
||||||
|
details={
|
||||||
|
"code_hash": code_hash,
|
||||||
|
"timeout": timeout,
|
||||||
|
"memory": memory,
|
||||||
|
"cpu_quota": cpu_quota
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if session exists, create if needed
|
||||||
|
try:
|
||||||
|
self.session_manager.get_session(session_id)
|
||||||
|
except SessionError:
|
||||||
|
# Session doesn't exist, create it with MCP injection
|
||||||
|
resource_limits = ResourceLimits(
|
||||||
|
memory=memory,
|
||||||
|
cpu_quota=cpu_quota,
|
||||||
|
storage="1g", # Default storage quota
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
self.session_manager.create_session(
|
||||||
|
session_id=session_id,
|
||||||
|
resource_limits=resource_limits,
|
||||||
|
volumes=volumes,
|
||||||
|
injection_code=injection_code,
|
||||||
|
bridge_socket_path=bridge_socket_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute in session
|
||||||
|
result = self.session_manager.execute_in_session(
|
||||||
|
session_id=session_id,
|
||||||
|
code=code,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def document_state(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
variables: Dict[str, str],
|
||||||
|
note: str = "",
|
||||||
|
clear: bool = False
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Document important variables in session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to document
|
||||||
|
variables: Dictionary of variable_name -> description
|
||||||
|
note: Optional note about session state
|
||||||
|
clear: If True, replace all documented variables; if False, merge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated state info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
self.session_manager.document_state(
|
||||||
|
session_id=session_id,
|
||||||
|
variables=variables,
|
||||||
|
note=note,
|
||||||
|
clear=clear
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return updated state
|
||||||
|
state = self.session_manager.get_session_state(session_id)
|
||||||
|
return state.to_dict()
|
||||||
|
|
||||||
|
def get_session_state(self, session_id: str) -> SessionState:
|
||||||
|
"""
|
||||||
|
Get documented state for session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionState object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
return self.session_manager.get_session_state(session_id)
|
||||||
|
|
||||||
|
def destroy_session(self, session_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Destroy session and cleanup kernel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to destroy
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
self.session_manager.destroy_session(session_id)
|
||||||
|
|
||||||
|
def list_sessions(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
List all active sessions with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of session dictionaries
|
||||||
|
"""
|
||||||
|
return self.session_manager.list_sessions()
|
||||||
|
|
||||||
|
def cleanup_idle_sessions(self) -> int:
|
||||||
|
"""
|
||||||
|
Cleanup sessions idle beyond configured timeout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sessions cleaned up
|
||||||
|
"""
|
||||||
|
return self.session_manager.cleanup_idle_sessions()
|
||||||
|
|
||||||
|
def _default_resource_limits(self) -> Optional[ResourceLimits]:
|
||||||
|
"""
|
||||||
|
Get default resource limits from config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResourceLimits with config defaults, or None if enforcement disabled
|
||||||
|
"""
|
||||||
|
if not self.config.security.enforce_resource_limits:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ResourceLimits(
|
||||||
|
memory=self.config.execution.default_memory,
|
||||||
|
cpu_quota=self.config.execution.default_cpu_quota,
|
||||||
|
storage="1g",
|
||||||
|
timeout=self.config.execution.default_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_limits(self, timeout: int, memory: str, cpu_quota: int) -> None:
|
||||||
|
"""
|
||||||
|
Validate resource limits against configured maximums.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Timeout in seconds
|
||||||
|
memory: Memory limit string
|
||||||
|
cpu_quota: CPU quota value
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any limit exceeds maximum
|
||||||
|
"""
|
||||||
|
# Validate timeout
|
||||||
|
if timeout > self.config.execution.max_timeout:
|
||||||
|
raise ValueError(
|
||||||
|
f"Timeout {timeout} exceeds maximum {self.config.execution.max_timeout}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate memory
|
||||||
|
memory_bytes = parse_memory_string(memory)
|
||||||
|
max_memory_bytes = parse_memory_string(self.config.execution.max_memory)
|
||||||
|
if memory_bytes > max_memory_bytes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Memory {memory} exceeds maximum {self.config.execution.max_memory}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate CPU quota
|
||||||
|
if cpu_quota > self.config.execution.max_cpu_quota:
|
||||||
|
raise ValueError(
|
||||||
|
f"CPU quota {cpu_quota} exceeds maximum {self.config.execution.max_cpu_quota}"
|
||||||
|
)
|
||||||
631
src/mcp_forge/execution/jupyter/kernel.py
Normal file
631
src/mcp_forge/execution/jupyter/kernel.py
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
"""
|
||||||
|
Real Jupyter kernel management for stateful execution.
|
||||||
|
|
||||||
|
This module implements proper Jupyter kernel management:
|
||||||
|
- jupyter-client runs on host (MCP-Forge server)
|
||||||
|
- ipykernel runs inside Podman containers
|
||||||
|
- Communication via ZMQ protocol
|
||||||
|
- 1:1 mapping: one container per session, one kernel per container
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional, List, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jupyter_client.blocking.client import BlockingKernelClient
|
||||||
|
import zmq
|
||||||
|
|
||||||
|
from mcp_forge.podman.containers import SecureContainerManager, ContainerConfig
|
||||||
|
from mcp_forge.security.resource_limits import ResourceLimits
|
||||||
|
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||||
|
|
||||||
|
|
||||||
|
class KernelError(Exception):
|
||||||
|
"""Raised when kernel operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KernelInfo:
|
||||||
|
"""Information about a running kernel."""
|
||||||
|
kernel_id: str
|
||||||
|
container_id: str
|
||||||
|
session_id: str
|
||||||
|
connection_file: Path
|
||||||
|
connection_info: Dict[str, Any] # ZMQ ports and keys
|
||||||
|
started_at: datetime
|
||||||
|
last_activity: datetime
|
||||||
|
client: Optional[BlockingKernelClient] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"kernel_id": self.kernel_id,
|
||||||
|
"container_id": self.container_id,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"started_at": self.started_at.isoformat(),
|
||||||
|
"last_activity": self.last_activity.isoformat(),
|
||||||
|
"connection_info": {
|
||||||
|
"shell_port": self.connection_info.get("shell_port"),
|
||||||
|
"iopub_port": self.connection_info.get("iopub_port"),
|
||||||
|
"stdin_port": self.connection_info.get("stdin_port"),
|
||||||
|
"control_port": self.connection_info.get("control_port"),
|
||||||
|
"hb_port": self.connection_info.get("hb_port"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JupyterKernelManager:
|
||||||
|
"""
|
||||||
|
Manages IPython kernels in containers via jupyter-client.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- This class runs on host (MCP-Forge server process)
|
||||||
|
- Creates one container per session with ipykernel running inside
|
||||||
|
- Connects to kernel via ZMQ protocol (jupyter-client)
|
||||||
|
- Communicates using Jupyter message protocol
|
||||||
|
|
||||||
|
Each session gets:
|
||||||
|
- Dedicated container
|
||||||
|
- Dedicated kernel process
|
||||||
|
- Isolated Python namespace
|
||||||
|
- Independent resource limits
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
container_manager: SecureContainerManager,
|
||||||
|
image: str,
|
||||||
|
resource_limits: Optional[ResourceLimits] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize kernel manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_manager: Container lifecycle manager
|
||||||
|
image: Docker/Podman image with ipykernel installed
|
||||||
|
resource_limits: Default resource limits for kernels
|
||||||
|
"""
|
||||||
|
self.container_manager = container_manager
|
||||||
|
self.image = image
|
||||||
|
self.resource_limits = resource_limits
|
||||||
|
self.kernels: Dict[str, KernelInfo] = {}
|
||||||
|
|
||||||
|
def start_kernel(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
volumes: Optional[Dict[str, dict]] = None,
|
||||||
|
injection_code: Optional[str] = None,
|
||||||
|
bridge_socket_path: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Start IPython kernel in dedicated container.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Generate ZMQ connection info (ports, keys)
|
||||||
|
2. Create connection file
|
||||||
|
3. Create container with ipykernel command
|
||||||
|
4. Mount bridge socket if provided (for MCP tools)
|
||||||
|
5. Start container
|
||||||
|
6. Wait for kernel to be ready
|
||||||
|
7. Connect jupyter-client to kernel via ZMQ
|
||||||
|
8. Execute injection code (MCP tools setup) if provided
|
||||||
|
9. Verify kernel is responsive
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID this kernel belongs to
|
||||||
|
volumes: Optional volume mounts
|
||||||
|
injection_code: Optional MCP tool injection code to execute at startup
|
||||||
|
bridge_socket_path: Optional path to MCP bridge socket for mounting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
kernel_id: Unique identifier for this kernel
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KernelError: If kernel startup fails
|
||||||
|
"""
|
||||||
|
kernel_id = f"kernel-{uuid.uuid4().hex[:16]}"
|
||||||
|
|
||||||
|
# Generate connection info
|
||||||
|
connection_info = self._generate_connection_info()
|
||||||
|
|
||||||
|
# Create connection file
|
||||||
|
connection_file = self._create_connection_file(kernel_id, connection_info)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set up volumes (user volumes + bridge socket + connection file)
|
||||||
|
container_volumes = volumes.copy() if volumes else {}
|
||||||
|
if bridge_socket_path:
|
||||||
|
container_volumes[bridge_socket_path] = {
|
||||||
|
"bind": bridge_socket_path,
|
||||||
|
"mode": "rw"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount connection file into container
|
||||||
|
container_connection_path = f"/tmp/kernel-{kernel_id}.json"
|
||||||
|
container_volumes[str(connection_file)] = {
|
||||||
|
"bind": container_connection_path,
|
||||||
|
"mode": "ro"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create container with ipykernel using host networking
|
||||||
|
config = ContainerConfig(
|
||||||
|
image=self.image,
|
||||||
|
command=[
|
||||||
|
"python", "-m", "ipykernel_launcher",
|
||||||
|
"-f", container_connection_path
|
||||||
|
],
|
||||||
|
resource_limits=self.resource_limits,
|
||||||
|
volumes=container_volumes,
|
||||||
|
network_mode="host", # Use host network for ZMQ communication
|
||||||
|
port_bindings={
|
||||||
|
connection_info["shell_port"]: connection_info["shell_port"],
|
||||||
|
connection_info["iopub_port"]: connection_info["iopub_port"],
|
||||||
|
connection_info["stdin_port"]: connection_info["stdin_port"],
|
||||||
|
connection_info["control_port"]: connection_info["control_port"],
|
||||||
|
connection_info["hb_port"]: connection_info["hb_port"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = self.container_manager.create_container(
|
||||||
|
config,
|
||||||
|
session_id=session_id,
|
||||||
|
name=f"jupyter-{kernel_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start container
|
||||||
|
self.container_manager.start_container(container_id)
|
||||||
|
|
||||||
|
# Wait for kernel to be ready with polling
|
||||||
|
if not self._wait_for_kernel_ready(connection_info, timeout=30):
|
||||||
|
raise KernelError(f"Kernel {kernel_id} failed to start within timeout")
|
||||||
|
|
||||||
|
# Connect client
|
||||||
|
client = self._connect_client(connection_info)
|
||||||
|
|
||||||
|
# Verify kernel is responsive
|
||||||
|
if not self._verify_kernel(client):
|
||||||
|
raise KernelError(f"Kernel {kernel_id} not responsive")
|
||||||
|
|
||||||
|
# Execute injection code if provided (MCP tools setup)
|
||||||
|
if injection_code:
|
||||||
|
self._execute_injection_code(client, injection_code, kernel_id)
|
||||||
|
|
||||||
|
# Register kernel
|
||||||
|
now = datetime.utcnow()
|
||||||
|
kernel_info = KernelInfo(
|
||||||
|
kernel_id=kernel_id,
|
||||||
|
container_id=container_id,
|
||||||
|
session_id=session_id,
|
||||||
|
connection_file=connection_file,
|
||||||
|
connection_info=connection_info,
|
||||||
|
started_at=now,
|
||||||
|
last_activity=now,
|
||||||
|
client=client
|
||||||
|
)
|
||||||
|
self.kernels[kernel_id] = kernel_info
|
||||||
|
|
||||||
|
return kernel_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Cleanup on failure
|
||||||
|
connection_file.unlink(missing_ok=True)
|
||||||
|
raise KernelError(f"Failed to start kernel: {e}") from e
|
||||||
|
|
||||||
|
def execute_code(
|
||||||
|
self,
|
||||||
|
kernel_id: str,
|
||||||
|
code: str,
|
||||||
|
timeout: int = 300
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute code in kernel via ZMQ.
|
||||||
|
|
||||||
|
Uses jupyter-client to:
|
||||||
|
1. Send execute_request message
|
||||||
|
2. Receive stream (stdout/stderr) messages
|
||||||
|
3. Receive execute_result/display_data messages
|
||||||
|
4. Collect and parse all output
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kernel_id: Kernel to execute in
|
||||||
|
code: Python code to execute
|
||||||
|
timeout: Maximum execution time in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExecutionResult with stdout, stderr, result
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KernelError: If kernel not found or execution fails
|
||||||
|
"""
|
||||||
|
kernel_info = self._get_kernel(kernel_id)
|
||||||
|
client = kernel_info.client
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
raise KernelError(f"Kernel {kernel_id} has no connected client")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute code
|
||||||
|
_msg_id = client.execute(code, silent=False, store_history=True)
|
||||||
|
|
||||||
|
# Collect output
|
||||||
|
stdout_parts = []
|
||||||
|
stderr_parts = []
|
||||||
|
result = None
|
||||||
|
has_error = False
|
||||||
|
|
||||||
|
# Wait for execution to complete
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = client.get_iopub_msg(timeout=timeout)
|
||||||
|
msg_type = msg['header']['msg_type']
|
||||||
|
content = msg['content']
|
||||||
|
|
||||||
|
if msg_type == 'stream':
|
||||||
|
if content['name'] == 'stdout':
|
||||||
|
stdout_parts.append(content['text'])
|
||||||
|
elif content['name'] == 'stderr':
|
||||||
|
stderr_parts.append(content['text'])
|
||||||
|
|
||||||
|
elif msg_type == 'execute_result':
|
||||||
|
result = content.get('data', {}).get('text/plain', '')
|
||||||
|
|
||||||
|
elif msg_type == 'error':
|
||||||
|
has_error = True
|
||||||
|
stderr_parts.append('\n'.join(content['traceback']))
|
||||||
|
|
||||||
|
elif msg_type == 'status':
|
||||||
|
if content['execution_state'] == 'idle':
|
||||||
|
break
|
||||||
|
|
||||||
|
except zmq.error.Again:
|
||||||
|
break
|
||||||
|
|
||||||
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
|
# Update last activity
|
||||||
|
kernel_info.last_activity = datetime.utcnow()
|
||||||
|
|
||||||
|
stderr_text = ''.join(stderr_parts)
|
||||||
|
|
||||||
|
return ExecutionResult(
|
||||||
|
success=(not has_error),
|
||||||
|
stdout=''.join(stdout_parts),
|
||||||
|
stderr=stderr_text,
|
||||||
|
result=result,
|
||||||
|
execution_time=execution_time,
|
||||||
|
exit_code=1 if has_error else 0,
|
||||||
|
error=stderr_text if has_error else None
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
execution_time = time.time() - start_time
|
||||||
|
return ExecutionResult(
|
||||||
|
success=False,
|
||||||
|
stdout='',
|
||||||
|
stderr=str(e),
|
||||||
|
result=None,
|
||||||
|
execution_time=execution_time,
|
||||||
|
exit_code=1,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown_kernel(self, kernel_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Shutdown kernel and cleanup container.
|
||||||
|
|
||||||
|
1. Send shutdown_request via ZMQ
|
||||||
|
2. Wait for kernel shutdown
|
||||||
|
3. Stop and remove container
|
||||||
|
4. Cleanup connection file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kernel_id: Kernel to shutdown
|
||||||
|
"""
|
||||||
|
kernel_info = self._get_kernel(kernel_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Shutdown kernel
|
||||||
|
if kernel_info.client:
|
||||||
|
kernel_info.client.shutdown()
|
||||||
|
kernel_info.client.stop_channels()
|
||||||
|
|
||||||
|
# Stop and remove container
|
||||||
|
self.container_manager.stop_container(kernel_info.container_id)
|
||||||
|
self.container_manager.remove_container(kernel_info.container_id)
|
||||||
|
|
||||||
|
# Cleanup connection file
|
||||||
|
kernel_info.connection_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Remove from registry
|
||||||
|
del self.kernels[kernel_id]
|
||||||
|
|
||||||
|
def inspect_namespace(self, kernel_id: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of variables in kernel namespace.
|
||||||
|
|
||||||
|
Executes introspection code:
|
||||||
|
[var for var in dir() if not var.startswith('_')]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kernel_id: Kernel to inspect
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of variable names
|
||||||
|
"""
|
||||||
|
code = "[var for var in dir() if not var.startswith('_')]"
|
||||||
|
result = self.execute_code(kernel_id, code, timeout=5)
|
||||||
|
|
||||||
|
if result.success and result.result:
|
||||||
|
# Parse result (it's a string representation of a list)
|
||||||
|
try:
|
||||||
|
return eval(result.result) # nosec - controlled code
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_variable_info(
|
||||||
|
self,
|
||||||
|
kernel_id: str,
|
||||||
|
variable_name: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get detailed information about a variable.
|
||||||
|
|
||||||
|
Executes introspection code to get:
|
||||||
|
- type(var).__name__
|
||||||
|
- sys.getsizeof(var) if available
|
||||||
|
- var.shape if hasattr(var, 'shape')
|
||||||
|
- repr(var)[:100]
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kernel_id: Kernel to inspect
|
||||||
|
variable_name: Name of variable to inspect
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with type, size, shape, repr
|
||||||
|
"""
|
||||||
|
code = f"""
|
||||||
|
import sys
|
||||||
|
_var = {variable_name}
|
||||||
|
_info = {{
|
||||||
|
'type': type(_var).__name__,
|
||||||
|
'repr': repr(_var)[:100],
|
||||||
|
}}
|
||||||
|
try:
|
||||||
|
_info['size_bytes'] = sys.getsizeof(_var)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if hasattr(_var, 'shape'):
|
||||||
|
_info['shape'] = _var.shape
|
||||||
|
_info
|
||||||
|
"""
|
||||||
|
result = self.execute_code(kernel_id, code, timeout=5)
|
||||||
|
|
||||||
|
if result.success and result.result:
|
||||||
|
try:
|
||||||
|
return eval(result.result) # nosec - controlled code
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def restart_kernel(self, kernel_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Restart kernel (namespace reset, container kept).
|
||||||
|
|
||||||
|
Strategy: shutdown current kernel and start new one in same container.
|
||||||
|
Note: In a full implementation, we'd use KernelManager.restart_kernel().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kernel_id: Kernel to restart
|
||||||
|
"""
|
||||||
|
kernel_info = self._get_kernel(kernel_id)
|
||||||
|
|
||||||
|
# For now, just record activity - full restart implementation requires
|
||||||
|
# KernelManager integration (not just BlockingKernelClient)
|
||||||
|
# TODO: Implement proper kernel restart via KernelManager
|
||||||
|
kernel_info.last_activity = datetime.utcnow()
|
||||||
|
|
||||||
|
def cleanup_idle_kernels(
|
||||||
|
self,
|
||||||
|
idle_timeout: timedelta
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Cleanup kernels idle longer than timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
idle_timeout: Maximum idle time before cleanup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of kernels cleaned up
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
cleaned_up = 0
|
||||||
|
|
||||||
|
for kernel_id in list(self.kernels.keys()):
|
||||||
|
kernel_info = self.kernels[kernel_id]
|
||||||
|
idle_time = now - kernel_info.last_activity
|
||||||
|
|
||||||
|
if idle_time > idle_timeout:
|
||||||
|
try:
|
||||||
|
self.shutdown_kernel(kernel_id)
|
||||||
|
cleaned_up += 1
|
||||||
|
except Exception:
|
||||||
|
pass # Continue cleanup even if one fails
|
||||||
|
|
||||||
|
return cleaned_up
|
||||||
|
|
||||||
|
def _get_kernel(self, kernel_id: str) -> KernelInfo:
|
||||||
|
"""Get kernel info or raise error."""
|
||||||
|
if kernel_id not in self.kernels:
|
||||||
|
raise KernelError(f"Kernel {kernel_id} not found")
|
||||||
|
return self.kernels[kernel_id]
|
||||||
|
|
||||||
|
def _generate_connection_info(self) -> Dict[str, Any]:
|
||||||
|
"""Generate ZMQ connection information with allocated ports."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
# Allocate 5 ports for ZMQ channels
|
||||||
|
ports = self._allocate_ports(5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"shell_port": ports[0],
|
||||||
|
"iopub_port": ports[1],
|
||||||
|
"stdin_port": ports[2],
|
||||||
|
"control_port": ports[3],
|
||||||
|
"hb_port": ports[4],
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"key": secrets.token_hex(32),
|
||||||
|
"transport": "tcp",
|
||||||
|
"signature_scheme": "hmac-sha256",
|
||||||
|
"kernel_name": "python3"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _allocate_ports(self, count: int) -> List[int]:
|
||||||
|
"""
|
||||||
|
Allocate available ports for ZMQ.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: Number of ports to allocate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of allocated port numbers
|
||||||
|
"""
|
||||||
|
ports = []
|
||||||
|
for _ in range(count):
|
||||||
|
# Let OS assign available port
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.bind(('127.0.0.1', 0)) # Bind to any available port
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
ports.append(port)
|
||||||
|
return ports
|
||||||
|
|
||||||
|
def _create_connection_file(
|
||||||
|
self,
|
||||||
|
kernel_id: str,
|
||||||
|
connection_info: Dict[str, Any]
|
||||||
|
) -> Path:
|
||||||
|
"""Create connection file for kernel."""
|
||||||
|
# Create temp file
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f"-kernel-{kernel_id}.json")
|
||||||
|
|
||||||
|
# Write connection info
|
||||||
|
with open(fd, 'w') as f:
|
||||||
|
json.dump(connection_info, f)
|
||||||
|
|
||||||
|
return Path(path)
|
||||||
|
|
||||||
|
def _connect_client(self, connection_info: Dict[str, Any]) -> BlockingKernelClient:
|
||||||
|
"""Connect jupyter-client to kernel."""
|
||||||
|
client = BlockingKernelClient()
|
||||||
|
client.load_connection_info(connection_info)
|
||||||
|
client.start_channels()
|
||||||
|
return client
|
||||||
|
|
||||||
|
def _verify_kernel(self, client: BlockingKernelClient, timeout: int = 10) -> bool:
|
||||||
|
"""Verify kernel is responsive."""
|
||||||
|
try:
|
||||||
|
client.wait_for_ready(timeout=timeout)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _wait_for_kernel_ready(
|
||||||
|
self,
|
||||||
|
connection_info: Dict[str, Any],
|
||||||
|
timeout: int = 30,
|
||||||
|
poll_interval: float = 0.5
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Wait for kernel to be ready by polling ports.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connection_info: Kernel connection information
|
||||||
|
timeout: Maximum time to wait in seconds
|
||||||
|
poll_interval: Time between polls in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if kernel is ready, False if timeout
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
shell_port = connection_info["shell_port"]
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
# Try to connect to shell port
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(1)
|
||||||
|
result = sock.connect_ex(('127.0.0.1', shell_port))
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if result == 0:
|
||||||
|
# Port is open, kernel is ready
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _execute_injection_code(
|
||||||
|
self,
|
||||||
|
client: BlockingKernelClient,
|
||||||
|
injection_code: str,
|
||||||
|
kernel_id: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Execute MCP tool injection code on kernel startup.
|
||||||
|
|
||||||
|
This runs once when the kernel starts to set up MCP tools.
|
||||||
|
Unlike regular code execution, we don't capture output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: Connected kernel client
|
||||||
|
injection_code: Python code to inject (MCP tools setup)
|
||||||
|
kernel_id: Kernel ID for error messages
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KernelError: If injection code fails to execute
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Execute injection code silently
|
||||||
|
_msg_id = client.execute(injection_code, silent=True, store_history=False)
|
||||||
|
|
||||||
|
# Wait for execution to complete
|
||||||
|
timeout = 10 # Injection should be fast
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = client.get_iopub_msg(timeout=timeout)
|
||||||
|
msg_type = msg['header']['msg_type']
|
||||||
|
|
||||||
|
if msg_type == 'error':
|
||||||
|
content = msg['content']
|
||||||
|
error_msg = '\n'.join(content.get('traceback', [str(content)]))
|
||||||
|
raise KernelError(
|
||||||
|
f"MCP injection failed in kernel {kernel_id}: {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msg_type == 'status':
|
||||||
|
if msg['content']['execution_state'] == 'idle':
|
||||||
|
break # Injection complete
|
||||||
|
|
||||||
|
except zmq.error.Again:
|
||||||
|
break # Timeout, assume success
|
||||||
|
|
||||||
|
except KernelError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise KernelError(
|
||||||
|
f"Failed to execute MCP injection code in kernel {kernel_id}: {e}"
|
||||||
|
) from e
|
||||||
435
src/mcp_forge/execution/jupyter/sessions.py
Normal file
435
src/mcp_forge/execution/jupyter/sessions.py
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
"""Session management for stateful execution."""
|
||||||
|
|
||||||
|
from typing import Dict, Optional, List, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from mcp_forge.execution.jupyter.kernel import JupyterKernelManager
|
||||||
|
from mcp_forge.config.schema import SessionConfig
|
||||||
|
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||||
|
from mcp_forge.security.resource_limits import ResourceLimits
|
||||||
|
from mcp_forge.execution.simple.executor import ExecutionResult
|
||||||
|
|
||||||
|
|
||||||
|
class SessionError(Exception):
|
||||||
|
"""Raised when session operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionState:
|
||||||
|
"""Documented state for a session."""
|
||||||
|
session_id: str
|
||||||
|
documented_variables: Dict[str, str] = field(default_factory=dict)
|
||||||
|
note: str = ""
|
||||||
|
last_updated: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
all_variables: List[str] = field(default_factory=list)
|
||||||
|
introspection: Dict[str, dict] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"documented_variables": self.documented_variables,
|
||||||
|
"note": self.note,
|
||||||
|
"last_updated": self.last_updated.isoformat(),
|
||||||
|
"all_variables": self.all_variables,
|
||||||
|
"introspection": self.introspection
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Stateful execution session."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
kernel_id: str,
|
||||||
|
created_at: datetime,
|
||||||
|
resource_limits: ResourceLimits
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Unique session identifier
|
||||||
|
kernel_id: ID of associated kernel
|
||||||
|
created_at: Session creation timestamp
|
||||||
|
resource_limits: Resource limits for this session
|
||||||
|
"""
|
||||||
|
self.session_id = session_id
|
||||||
|
self.kernel_id = kernel_id
|
||||||
|
self.created_at = created_at
|
||||||
|
self.last_activity = created_at
|
||||||
|
self.resource_limits = resource_limits
|
||||||
|
self.state = SessionState(session_id=session_id)
|
||||||
|
self.documented_variables: Dict[str, str] = {}
|
||||||
|
self.documentation_note: Optional[str] = None
|
||||||
|
|
||||||
|
def update_activity(self) -> None:
|
||||||
|
"""Update last activity timestamp."""
|
||||||
|
self.last_activity = datetime.utcnow()
|
||||||
|
|
||||||
|
def is_idle(self, timeout: timedelta) -> bool:
|
||||||
|
"""
|
||||||
|
Check if session is idle beyond timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum idle time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session has been idle longer than timeout
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
idle_time = now - self.last_activity
|
||||||
|
return idle_time > timeout
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"kernel_id": self.kernel_id,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"last_activity": self.last_activity.isoformat(),
|
||||||
|
"state": self.state.to_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Manages stateful execution sessions."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: SessionConfig,
|
||||||
|
kernel_manager: JupyterKernelManager,
|
||||||
|
audit_logger: AuditLogger
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize session manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Session configuration
|
||||||
|
kernel_manager: Kernel lifecycle manager
|
||||||
|
audit_logger: Audit logging instance
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.kernel_manager = kernel_manager
|
||||||
|
self.audit_logger = audit_logger
|
||||||
|
self.sessions: Dict[str, Session] = {}
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
resource_limits: ResourceLimits,
|
||||||
|
volumes: Optional[Dict[str, dict]] = None,
|
||||||
|
injection_code: Optional[str] = None,
|
||||||
|
bridge_socket_path: Optional[str] = None
|
||||||
|
) -> Session:
|
||||||
|
"""
|
||||||
|
Create new stateful session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Unique identifier for session
|
||||||
|
resource_limits: Resource limits for session
|
||||||
|
volumes: Optional volume mounts
|
||||||
|
injection_code: Optional MCP tool injection code to execute at startup
|
||||||
|
bridge_socket_path: Optional path to MCP bridge socket for mounting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Session object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session_id already exists
|
||||||
|
SessionError: If max concurrent sessions exceeded
|
||||||
|
"""
|
||||||
|
if session_id in self.sessions:
|
||||||
|
raise SessionError(f"Session {session_id} already exists")
|
||||||
|
|
||||||
|
# Check max concurrent limit
|
||||||
|
self._enforce_max_concurrent()
|
||||||
|
|
||||||
|
# Start kernel with MCP injection if provided
|
||||||
|
kernel_id = self.kernel_manager.start_kernel(
|
||||||
|
session_id,
|
||||||
|
volumes=volumes,
|
||||||
|
injection_code=injection_code,
|
||||||
|
bridge_socket_path=bridge_socket_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
now = datetime.utcnow()
|
||||||
|
session = Session(
|
||||||
|
session_id=session_id,
|
||||||
|
kernel_id=kernel_id,
|
||||||
|
created_at=now,
|
||||||
|
resource_limits=resource_limits
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sessions[session_id] = session
|
||||||
|
|
||||||
|
# Log session creation
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.SESSION_CREATE,
|
||||||
|
severity=AuditSeverity.INFO,
|
||||||
|
message=f"Session created: {session_id}",
|
||||||
|
session_id=session_id,
|
||||||
|
details={
|
||||||
|
"kernel_id": kernel_id,
|
||||||
|
"memory": resource_limits.memory_bytes,
|
||||||
|
"cpu_quota": resource_limits.cpu_quota
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def session_exists(self, session_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if session exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session exists, False otherwise
|
||||||
|
"""
|
||||||
|
return session_id in self.sessions
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> Session:
|
||||||
|
"""
|
||||||
|
Get session by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
raise SessionError(f"Session {session_id} not found")
|
||||||
|
|
||||||
|
return self.sessions[session_id]
|
||||||
|
|
||||||
|
async def document_variables(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
variables: Dict[str, str],
|
||||||
|
note: Optional[str] = None,
|
||||||
|
clear: bool = False
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Document important variables in a session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
variables: Dict mapping variable names to descriptions
|
||||||
|
note: Optional general note about session state
|
||||||
|
clear: Whether to clear existing documentation first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result dict with success status and documented count
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
|
||||||
|
if clear:
|
||||||
|
session.documented_variables = {}
|
||||||
|
|
||||||
|
# Store variable documentation in session
|
||||||
|
if not hasattr(session, 'documented_variables'):
|
||||||
|
session.documented_variables = {}
|
||||||
|
|
||||||
|
session.documented_variables.update(variables)
|
||||||
|
|
||||||
|
if note:
|
||||||
|
session.documentation_note = note
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"documented_count": len(variables),
|
||||||
|
"total_documented": len(session.documented_variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute_in_session(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
code: str,
|
||||||
|
timeout: int = 300
|
||||||
|
) -> ExecutionResult:
|
||||||
|
"""
|
||||||
|
Execute code in session kernel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to execute in
|
||||||
|
code: Python code to execute
|
||||||
|
timeout: Maximum execution time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExecutionResult with output
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
|
||||||
|
# Update activity
|
||||||
|
session.update_activity()
|
||||||
|
|
||||||
|
# Execute in kernel
|
||||||
|
result = self.kernel_manager.execute_code(
|
||||||
|
session.kernel_id,
|
||||||
|
code,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def document_state(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
variables: Dict[str, str],
|
||||||
|
note: str = "",
|
||||||
|
clear: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Document important variables in session.
|
||||||
|
|
||||||
|
Updates session.state with variable descriptions and runs
|
||||||
|
introspection to capture current namespace state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to document
|
||||||
|
variables: Dictionary of variable_name -> description
|
||||||
|
note: Optional note about session state
|
||||||
|
clear: If True, replace all documented variables; if False, merge
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
|
||||||
|
# Update documented variables
|
||||||
|
if clear:
|
||||||
|
session.state.documented_variables = variables.copy()
|
||||||
|
else:
|
||||||
|
session.state.documented_variables.update(variables)
|
||||||
|
|
||||||
|
# Update note if provided
|
||||||
|
if note:
|
||||||
|
session.state.note = note
|
||||||
|
|
||||||
|
# Run introspection to get current namespace state
|
||||||
|
session.state.all_variables = self.kernel_manager.inspect_namespace(session.kernel_id)
|
||||||
|
|
||||||
|
# Get variable info for documented variables
|
||||||
|
session.state.introspection = {}
|
||||||
|
for var_name in variables.keys():
|
||||||
|
if var_name in session.state.all_variables:
|
||||||
|
try:
|
||||||
|
info = self.kernel_manager.get_variable_info(session.kernel_id, var_name)
|
||||||
|
session.state.introspection[var_name] = info
|
||||||
|
except Exception:
|
||||||
|
# Variable might not exist yet
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update timestamp
|
||||||
|
session.state.last_updated = datetime.utcnow()
|
||||||
|
session.update_activity()
|
||||||
|
|
||||||
|
def get_session_state(self, session_id: str) -> SessionState:
|
||||||
|
"""
|
||||||
|
Get documented state for session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SessionState object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
return session.state
|
||||||
|
|
||||||
|
def destroy_session(self, session_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Destroy session and cleanup kernel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to destroy
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If session doesn't exist
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
|
||||||
|
# Shutdown kernel
|
||||||
|
try:
|
||||||
|
self.kernel_manager.shutdown_kernel(session.kernel_id)
|
||||||
|
except Exception as e:
|
||||||
|
# Log but continue with cleanup
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.SESSION_DESTROY,
|
||||||
|
severity=AuditSeverity.WARNING,
|
||||||
|
message=f"Error shutting down kernel for session {session_id}",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove session
|
||||||
|
del self.sessions[session_id]
|
||||||
|
|
||||||
|
# Log destruction
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.SESSION_DESTROY,
|
||||||
|
severity=AuditSeverity.INFO,
|
||||||
|
message=f"Session destroyed: {session_id}",
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup_idle_sessions(self) -> int:
|
||||||
|
"""
|
||||||
|
Cleanup sessions idle beyond configured timeout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sessions cleaned up
|
||||||
|
"""
|
||||||
|
timeout = timedelta(seconds=self.config.idle_timeout)
|
||||||
|
sessions_to_remove = []
|
||||||
|
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
if session.is_idle(timeout):
|
||||||
|
sessions_to_remove.append(session_id)
|
||||||
|
|
||||||
|
# Destroy idle sessions
|
||||||
|
for session_id in sessions_to_remove:
|
||||||
|
try:
|
||||||
|
self.destroy_session(session_id)
|
||||||
|
except Exception:
|
||||||
|
# Best effort cleanup
|
||||||
|
pass
|
||||||
|
|
||||||
|
return len(sessions_to_remove)
|
||||||
|
|
||||||
|
def list_sessions(self) -> List[dict]:
|
||||||
|
"""
|
||||||
|
List all active sessions with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of session dictionaries
|
||||||
|
"""
|
||||||
|
return [session.to_dict() for session in self.sessions.values()]
|
||||||
|
|
||||||
|
def _enforce_max_concurrent(self) -> None:
|
||||||
|
"""
|
||||||
|
Enforce max concurrent sessions limit.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionError: If at max concurrent sessions
|
||||||
|
"""
|
||||||
|
if len(self.sessions) >= self.config.max_concurrent:
|
||||||
|
raise SessionError(
|
||||||
|
f"Maximum concurrent sessions ({self.config.max_concurrent}) reached"
|
||||||
|
)
|
||||||
508
src/mcp_forge/podman/containers.py
Normal file
508
src/mcp_forge/podman/containers.py
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
"""
|
||||||
|
Secure container management with security enforcement.
|
||||||
|
|
||||||
|
All container operations are validated against security policy
|
||||||
|
before being sent to Podman. Provides lifecycle management
|
||||||
|
with comprehensive audit logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcp_forge.podman.client import PodmanClient
|
||||||
|
from mcp_forge.security.allowlist import OperationValidator, SecurityError
|
||||||
|
from mcp_forge.security.audit import AuditLogger, AuditEventType, AuditSeverity
|
||||||
|
from mcp_forge.security.resource_limits import ResourceLimits
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerConfig:
|
||||||
|
"""Container configuration with security defaults."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
image: str,
|
||||||
|
command: Optional[List[str]] = None,
|
||||||
|
environment: Optional[Dict[str, str]] = None,
|
||||||
|
volumes: Optional[Dict[str, dict]] = None,
|
||||||
|
resource_limits: Optional[ResourceLimits] = None,
|
||||||
|
working_dir: Optional[str] = None,
|
||||||
|
user: str = "1000:1000",
|
||||||
|
network_mode: str = "none",
|
||||||
|
port_bindings: Optional[Dict[str, int]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize container configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Container image to use
|
||||||
|
command: Command to run in container
|
||||||
|
environment: Environment variables
|
||||||
|
volumes: Volume mounts (host_path -> {bind, mode})
|
||||||
|
resource_limits: Resource limits to apply
|
||||||
|
working_dir: Working directory in container (None to use image default)
|
||||||
|
user: User to run as (UID:GID)
|
||||||
|
network_mode: Network mode (none, host, bridge). Default is 'none' for security.
|
||||||
|
port_bindings: Port mappings for network_mode=host (container_port -> host_port)
|
||||||
|
"""
|
||||||
|
self.image = image
|
||||||
|
self.command = command or []
|
||||||
|
self.environment = environment or {}
|
||||||
|
self.volumes = volumes or {}
|
||||||
|
self.resource_limits = resource_limits
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.user = user
|
||||||
|
self.network_mode = network_mode
|
||||||
|
self.port_bindings = port_bindings or {}
|
||||||
|
|
||||||
|
def to_podman_params(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert to Podman container create parameters.
|
||||||
|
|
||||||
|
Ensures all security requirements are included:
|
||||||
|
- network_mode: configurable (default 'none' for security)
|
||||||
|
- read_only: True
|
||||||
|
- security_opt: ["no-new-privileges"]
|
||||||
|
- resource limits
|
||||||
|
- port_bindings: for host networking mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of parameters for Podman containers.create()
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"image": self.image,
|
||||||
|
"command": self.command if self.command else None,
|
||||||
|
"environment": self.environment,
|
||||||
|
"user": self.user,
|
||||||
|
# Security requirements
|
||||||
|
"network_mode": self.network_mode,
|
||||||
|
"read_only": True,
|
||||||
|
"security_opt": ["no-new-privileges"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add working_dir only if explicitly set
|
||||||
|
if self.working_dir is not None:
|
||||||
|
params["working_dir"] = self.working_dir
|
||||||
|
|
||||||
|
# Add port bindings if using host network mode
|
||||||
|
# Note: In host mode, ports are directly accessible
|
||||||
|
# port_bindings are informational for tracking
|
||||||
|
if self.network_mode == "host" and self.port_bindings:
|
||||||
|
# With host networking, container uses host's network stack directly
|
||||||
|
# No explicit port mapping needed, but we track for documentation
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add volumes if present
|
||||||
|
if self.volumes:
|
||||||
|
params["volumes"] = self.volumes
|
||||||
|
|
||||||
|
# Add resource limits if present
|
||||||
|
if self.resource_limits:
|
||||||
|
limit_params = self.resource_limits.to_podman_params()
|
||||||
|
params.update(limit_params)
|
||||||
|
# Disable swap to avoid cgroup swap.max issues on some systems
|
||||||
|
if "mem_limit" in params:
|
||||||
|
params["memswap_limit"] = -1 # Disable swap
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
class SecureContainerManager:
|
||||||
|
"""Manages container lifecycle with security enforcement."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
podman_client: PodmanClient,
|
||||||
|
validator: OperationValidator,
|
||||||
|
audit_logger: AuditLogger
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize secure container manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
podman_client: Podman client wrapper
|
||||||
|
validator: Operation validator for security checks
|
||||||
|
audit_logger: Audit logger for operation logging
|
||||||
|
"""
|
||||||
|
self.podman = podman_client
|
||||||
|
self.validator = validator
|
||||||
|
self.audit_logger = audit_logger
|
||||||
|
|
||||||
|
def create_container(
|
||||||
|
self,
|
||||||
|
config: ContainerConfig,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
**extra_params
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Create a container with security validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Container configuration
|
||||||
|
session_id: Session ID for tracking
|
||||||
|
name: Optional container name
|
||||||
|
**extra_params: Additional parameters (checked for forbidden values)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SecurityError: If configuration violates security policy
|
||||||
|
"""
|
||||||
|
# Convert config to Podman parameters
|
||||||
|
params = config.to_podman_params()
|
||||||
|
|
||||||
|
# Add session label if provided
|
||||||
|
labels = {}
|
||||||
|
if session_id:
|
||||||
|
labels["mcp-forge.session"] = session_id
|
||||||
|
if labels:
|
||||||
|
params["labels"] = labels
|
||||||
|
|
||||||
|
if name:
|
||||||
|
params["name"] = name
|
||||||
|
|
||||||
|
# Merge any extra parameters (will be validated)
|
||||||
|
params.update(extra_params)
|
||||||
|
|
||||||
|
# Validate against security policy
|
||||||
|
try:
|
||||||
|
# Extract image from params for validation
|
||||||
|
self.validator.validate_container_create(
|
||||||
|
image=config.image,
|
||||||
|
params=params,
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
except SecurityError as e:
|
||||||
|
# Log security violation
|
||||||
|
self.audit_logger.log_security_violation(
|
||||||
|
operation="container_create",
|
||||||
|
reason=str(e),
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Create container
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.create(**params)
|
||||||
|
container_id = container.id
|
||||||
|
|
||||||
|
# Register with validator
|
||||||
|
if session_id:
|
||||||
|
self.validator.register_session_container(container_id)
|
||||||
|
|
||||||
|
# Log successful creation
|
||||||
|
self.audit_logger.log_container_operation(
|
||||||
|
operation="create",
|
||||||
|
container_id=container_id,
|
||||||
|
image=config.image,
|
||||||
|
session_id=session_id,
|
||||||
|
details={
|
||||||
|
"name": name,
|
||||||
|
"command": config.command
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return container_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_CREATE,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Container creation failed: {e}",
|
||||||
|
details={
|
||||||
|
"image": config.image,
|
||||||
|
"session_id": session_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def start_container(self, container_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Start a container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Container ID to start
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SecurityError: If container is not a session container
|
||||||
|
"""
|
||||||
|
# Verify container is registered (security check)
|
||||||
|
if container_id not in self.validator.session_containers:
|
||||||
|
self.audit_logger.log_security_violation(
|
||||||
|
operation="container_start",
|
||||||
|
reason=f"Attempted to start unregistered container: {container_id}"
|
||||||
|
)
|
||||||
|
raise SecurityError(
|
||||||
|
f"Container {container_id} is not a registered session container"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.get(container_id)
|
||||||
|
container.start()
|
||||||
|
|
||||||
|
self.audit_logger.log_container_operation(
|
||||||
|
operation="start",
|
||||||
|
container_id=container_id,
|
||||||
|
image="" # Not available without extra lookup
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_START,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Container start failed: {e}",
|
||||||
|
details={"container_id": container_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def stop_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: int = 10
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Stop a container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Container ID to stop
|
||||||
|
timeout: Timeout in seconds
|
||||||
|
"""
|
||||||
|
# Verify container is registered
|
||||||
|
if container_id not in self.validator.session_containers:
|
||||||
|
self.audit_logger.log_security_violation(
|
||||||
|
operation="container_stop",
|
||||||
|
reason=f"Attempted to stop unregistered container: {container_id}"
|
||||||
|
)
|
||||||
|
raise SecurityError(
|
||||||
|
f"Container {container_id} is not a registered session container"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.get(container_id)
|
||||||
|
container.stop(timeout=timeout)
|
||||||
|
|
||||||
|
self.audit_logger.log_container_operation(
|
||||||
|
operation="stop",
|
||||||
|
container_id=container_id,
|
||||||
|
image="",
|
||||||
|
details={"timeout": timeout}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_STOP,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Container stop failed: {e}",
|
||||||
|
details={"container_id": container_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def remove_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
force: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Remove a container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Container ID to remove
|
||||||
|
force: Force removal even if running
|
||||||
|
"""
|
||||||
|
# Verify container is registered
|
||||||
|
if container_id not in self.validator.session_containers:
|
||||||
|
self.audit_logger.log_security_violation(
|
||||||
|
operation="container_remove",
|
||||||
|
reason=f"Attempted to remove unregistered container: {container_id}"
|
||||||
|
)
|
||||||
|
raise SecurityError(
|
||||||
|
f"Container {container_id} is not a registered session container"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.get(container_id)
|
||||||
|
container.remove(force=force)
|
||||||
|
|
||||||
|
# Unregister from validator
|
||||||
|
self.validator.unregister_session_container(container_id)
|
||||||
|
|
||||||
|
self.audit_logger.log_container_operation(
|
||||||
|
operation="remove",
|
||||||
|
container_id=container_id,
|
||||||
|
image="",
|
||||||
|
details={"force": force}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_REMOVE,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Container removal failed: {e}",
|
||||||
|
details={"container_id": container_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_container_logs(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
tail: int = 100
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Get container stdout and stderr logs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Container ID
|
||||||
|
tail: Number of lines to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(stdout, stderr) as strings
|
||||||
|
"""
|
||||||
|
if container_id not in self.validator.session_containers:
|
||||||
|
raise SecurityError(
|
||||||
|
f"Container {container_id} is not a registered session container"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.get(container_id)
|
||||||
|
logs = container.logs(tail=tail, stdout=True, stderr=True)
|
||||||
|
|
||||||
|
# Podman logs returns a generator of frames, need to consume it
|
||||||
|
if hasattr(logs, '__iter__') and not isinstance(logs, (str, bytes)):
|
||||||
|
# It's a generator/iterator, consume it
|
||||||
|
logs_bytes = b''.join(logs)
|
||||||
|
logs_str = logs_bytes.decode('utf-8', errors='replace')
|
||||||
|
elif isinstance(logs, bytes):
|
||||||
|
logs_str = logs.decode('utf-8', errors='replace')
|
||||||
|
else:
|
||||||
|
logs_str = str(logs)
|
||||||
|
|
||||||
|
# For simplicity, return all logs in stdout (Podman combines them)
|
||||||
|
return logs_str, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.EXECUTION_REQUEST,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Failed to get container logs: {e}",
|
||||||
|
details={"container_id": container_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def wait_for_container(
|
||||||
|
self,
|
||||||
|
container_id: str,
|
||||||
|
timeout: int = 300
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Wait for container to exit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Container ID
|
||||||
|
timeout: Timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: If container doesn't exit within timeout
|
||||||
|
"""
|
||||||
|
if container_id not in self.validator.session_containers:
|
||||||
|
raise SecurityError(
|
||||||
|
f"Container {container_id} is not a registered session container"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
container = self.podman.client.containers.get(container_id)
|
||||||
|
result = container.wait(timeout=timeout)
|
||||||
|
|
||||||
|
# Extract exit code from result
|
||||||
|
if isinstance(result, dict):
|
||||||
|
exit_code = result.get("StatusCode", 0)
|
||||||
|
else:
|
||||||
|
exit_code = result
|
||||||
|
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.EXECUTION_REQUEST,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Failed to wait for container: {e}",
|
||||||
|
details={"container_id": container_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cleanup_old_containers(
|
||||||
|
self,
|
||||||
|
max_age: timedelta = timedelta(hours=24)
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Cleanup containers older than max_age.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age: Maximum age for containers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of containers removed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all containers with mcp-forge.session label
|
||||||
|
containers = self.podman.client.containers.list(
|
||||||
|
all=True,
|
||||||
|
filters={"label": ["mcp-forge.session"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
removed_count = 0
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for container in containers:
|
||||||
|
# Get creation time
|
||||||
|
created_str = container.attrs.get("Created", "")
|
||||||
|
if not created_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse ISO format timestamp
|
||||||
|
try:
|
||||||
|
# Remove fractional seconds and timezone for parsing
|
||||||
|
created_str = created_str.split('.')[0]
|
||||||
|
created = datetime.fromisoformat(created_str.replace('Z', ''))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
age = now - created
|
||||||
|
|
||||||
|
if age > max_age:
|
||||||
|
try:
|
||||||
|
container.remove(force=True)
|
||||||
|
removed_count += 1
|
||||||
|
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_REMOVE,
|
||||||
|
severity=AuditSeverity.INFO,
|
||||||
|
message=f"Cleaned up old container: {container.id}",
|
||||||
|
details={
|
||||||
|
"container_id": container.id,
|
||||||
|
"age_hours": age.total_seconds() / 3600
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_REMOVE,
|
||||||
|
severity=AuditSeverity.WARNING,
|
||||||
|
message=f"Failed to remove old container: {e}",
|
||||||
|
details={"container_id": container.id, "error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
return removed_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.audit_logger.log(
|
||||||
|
event_type=AuditEventType.CONTAINER_REMOVE,
|
||||||
|
severity=AuditSeverity.ERROR,
|
||||||
|
message=f"Cleanup failed: {e}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
@ -406,7 +406,7 @@ print(f"Found {len(records)} records")
|
||||||
# Create resource handler
|
# Create resource handler
|
||||||
self.resource_handler = ResourceHandler(
|
self.resource_handler = ResourceHandler(
|
||||||
client_manager=self.client_manager,
|
client_manager=self.client_manager,
|
||||||
jupyter_backend=self.jupyter_backend,
|
session_manager=self.jupyter_backend,
|
||||||
environment_builder=self.environment_builder,
|
environment_builder=self.environment_builder,
|
||||||
config=self.config
|
config=self.config
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,18 @@ class ContainerConfig:
|
||||||
params["volumes"] = self.volumes
|
params["volumes"] = self.volumes
|
||||||
|
|
||||||
# Add resource limits if present
|
# Add resource limits if present
|
||||||
|
# Skip resource limits if using very high values (indicates no enforcement)
|
||||||
if self.resource_limits:
|
if self.resource_limits:
|
||||||
limit_params = self.resource_limits.to_podman_params()
|
limit_params = self.resource_limits.to_podman_params()
|
||||||
params.update(limit_params)
|
# Only apply limits if they're reasonable (not "no enforcement" markers)
|
||||||
# Disable swap to avoid cgroup swap.max issues on some systems
|
# Check if mem_limit looks like an enforcement bypass (>= 16GB)
|
||||||
if "mem_limit" in params:
|
mem_limit = limit_params.get("mem_limit", "0")
|
||||||
params["memswap_limit"] = -1 # Disable swap
|
mem_bytes = int(mem_limit) if mem_limit != "0" else 0
|
||||||
|
if mem_bytes < 16 * 1024 * 1024 * 1024: # Less than 16GB = real limit
|
||||||
|
params.update(limit_params)
|
||||||
|
# Disable swap to avoid cgroup swap.max issues on some systems
|
||||||
|
if "mem_limit" in params:
|
||||||
|
params["memswap_limit"] = -1 # Disable swap
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,15 @@ class JupyterBackend:
|
||||||
# Initialize kernel manager
|
# Initialize kernel manager
|
||||||
kernel_manager = JupyterKernelManager(
|
kernel_manager = JupyterKernelManager(
|
||||||
container_manager=container_manager,
|
container_manager=container_manager,
|
||||||
image=config.images.jupyter,
|
image=image,
|
||||||
resource_limits=self._default_resource_limits()
|
resource_limits=self._default_resource_limits()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize session manager
|
# Initialize session manager
|
||||||
self.session_manager = SessionManager(
|
self.session_manager = SessionManager(
|
||||||
config=config.sessions,
|
|
||||||
kernel_manager=kernel_manager,
|
kernel_manager=kernel_manager,
|
||||||
|
idle_timeout=idle_timeout,
|
||||||
|
max_sessions=max_sessions,
|
||||||
audit_logger=audit_logger
|
audit_logger=audit_logger
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -109,7 +110,8 @@ class JupyterBackend:
|
||||||
|
|
||||||
# Log execution (hash code, don't log actual content)
|
# Log execution (hash code, don't log actual content)
|
||||||
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||||
self.audit_logger.log(
|
if self.audit_logger:
|
||||||
|
self.audit_logger.log(
|
||||||
event_type="execution.request",
|
event_type="execution.request",
|
||||||
severity="info",
|
severity="info",
|
||||||
message="Stateful code execution requested",
|
message="Stateful code execution requested",
|
||||||
|
|
@ -236,9 +238,7 @@ class JupyterBackend:
|
||||||
Returns:
|
Returns:
|
||||||
ResourceLimits with config defaults, or None if enforcement disabled
|
ResourceLimits with config defaults, or None if enforcement disabled
|
||||||
"""
|
"""
|
||||||
if not self.config.security.enforce_resource_limits:
|
# Always return resource limits in pod_executor
|
||||||
return None
|
|
||||||
|
|
||||||
return ResourceLimits(
|
return ResourceLimits(
|
||||||
memory=self.default_memory,
|
memory=self.default_memory,
|
||||||
cpu_quota=self.default_cpu_quota,
|
cpu_quota=self.default_cpu_quota,
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,46 @@ class SimpleFileAuditLogger:
|
||||||
# Write to file (append mode, file locking via 'a' mode)
|
# Write to file (append mode, file locking via 'a' mode)
|
||||||
with open(self.log_path, 'a') as f:
|
with open(self.log_path, 'a') as f:
|
||||||
f.write(json.dumps(entry) + '\n')
|
f.write(json.dumps(entry) + '\n')
|
||||||
|
|
||||||
|
def log_container_operation(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
container_id: str,
|
||||||
|
image: str,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
error: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Convenience method to log container operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operation: Operation type (create, start, stop, remove)
|
||||||
|
container_id: Container ID
|
||||||
|
image: Container image name
|
||||||
|
session_id: Optional session ID
|
||||||
|
user_id: Optional user ID
|
||||||
|
details: Optional additional details
|
||||||
|
error: Optional error message
|
||||||
|
"""
|
||||||
|
severity = "error" if error else "info"
|
||||||
|
message = f"Container {operation}: {container_id[:12]} (image: {image})"
|
||||||
|
|
||||||
|
op_details = {
|
||||||
|
"operation": operation,
|
||||||
|
"container_id": container_id,
|
||||||
|
"image": image
|
||||||
|
}
|
||||||
|
if details:
|
||||||
|
op_details.update(details)
|
||||||
|
|
||||||
|
self.log(
|
||||||
|
event_type=f"container.{operation}",
|
||||||
|
severity=severity,
|
||||||
|
message=message,
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
details=op_details,
|
||||||
|
error=error
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue