mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-07-20 11:00:50 +02:00
rename: FuzzForge → SecPipe
Rename the entire project from FuzzForge to SecPipe: - Python packages: fuzzforge_cli → secpipe_cli, fuzzforge_common → secpipe_common, fuzzforge_mcp → secpipe_mcp, fuzzforge_tests → secpipe_tests - Directories: fuzzforge-cli → secpipe-cli, fuzzforge-common → secpipe-common, fuzzforge-mcp → secpipe-mcp, fuzzforge-tests → secpipe-tests - Environment variables: FUZZFORGE_* → SECPIPE_* - MCP server name: SecPipe MCP Server - CI workflows, Makefile, Dockerfile, hub-config, NOTICE updated - Fix mcp-server.yml to use uvicorn secpipe_mcp.application:app
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""SecPipe Common - Shared abstractions and implementations for SecPipe.
|
||||
|
||||
This package provides:
|
||||
- Sandbox engine abstractions (Podman, Docker)
|
||||
- Common exceptions
|
||||
|
||||
Example usage:
|
||||
from secpipe_common import (
|
||||
AbstractSecPipeSandboxEngine,
|
||||
ImageInfo,
|
||||
Podman,
|
||||
PodmanConfiguration,
|
||||
)
|
||||
"""
|
||||
|
||||
from secpipe_common.exceptions import SecPipeError
|
||||
from secpipe_common.sandboxes import (
|
||||
AbstractSecPipeEngineConfiguration,
|
||||
AbstractSecPipeSandboxEngine,
|
||||
Docker,
|
||||
DockerConfiguration,
|
||||
SecPipeSandboxEngines,
|
||||
ImageInfo,
|
||||
Podman,
|
||||
PodmanConfiguration,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AbstractSecPipeEngineConfiguration",
|
||||
"AbstractSecPipeSandboxEngine",
|
||||
"Docker",
|
||||
"DockerConfiguration",
|
||||
"SecPipeError",
|
||||
"SecPipeSandboxEngines",
|
||||
"ImageInfo",
|
||||
"Podman",
|
||||
"PodmanConfiguration",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SecPipeError(Exception):
|
||||
"""Base exception for all SecPipe custom exceptions.
|
||||
|
||||
All domain exceptions should inherit from this base to enable
|
||||
consistent exception handling and hierarchy navigation.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
||||
"""Initialize SecPipe error.
|
||||
|
||||
:param message: Error message.
|
||||
:param details: Optional error details dictionary.
|
||||
|
||||
"""
|
||||
Exception.__init__(self, message)
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""SecPipe Hub - Generic MCP server bridge.
|
||||
|
||||
This module provides a generic bridge to connect SecPipe with any MCP server.
|
||||
It allows AI agents to discover and execute tools from external MCP servers
|
||||
(like mcp-security-hub) through the same interface as native SecPipe modules.
|
||||
|
||||
The hub is server-agnostic: it doesn't hardcode any specific tools or servers.
|
||||
Instead, it dynamically discovers tools by connecting to configured MCP servers
|
||||
and calling their `list_tools()` method.
|
||||
|
||||
Supported transport types:
|
||||
- docker: Run MCP server as a Docker container with stdio transport
|
||||
- command: Run MCP server as a local process with stdio transport
|
||||
- sse: Connect to a remote MCP server via Server-Sent Events
|
||||
|
||||
"""
|
||||
|
||||
from secpipe_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||
from secpipe_common.hub.executor import HubExecutionResult, HubExecutor
|
||||
from secpipe_common.hub.models import (
|
||||
HubConfig,
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
HubServerType,
|
||||
HubTool,
|
||||
HubToolParameter,
|
||||
)
|
||||
from secpipe_common.hub.registry import HubRegistry
|
||||
|
||||
__all__ = [
|
||||
"HubClient",
|
||||
"HubClientError",
|
||||
"HubConfig",
|
||||
"HubExecutionResult",
|
||||
"HubExecutor",
|
||||
"HubRegistry",
|
||||
"HubServer",
|
||||
"HubServerConfig",
|
||||
"HubServerType",
|
||||
"HubTool",
|
||||
"HubToolParameter",
|
||||
"PersistentSession",
|
||||
]
|
||||
@@ -0,0 +1,753 @@
|
||||
"""MCP client for communicating with hub servers.
|
||||
|
||||
This module provides a generic MCP client that can connect to any MCP server
|
||||
via stdio (docker/command) or SSE transport. It handles:
|
||||
- Starting containers/processes for stdio transport
|
||||
- Connecting to SSE endpoints
|
||||
- Discovering tools via list_tools()
|
||||
- Executing tools via call_tool()
|
||||
- Persistent container sessions for stateful interactions
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from secpipe_common.hub.models import (
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
HubServerType,
|
||||
HubTool,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubClientError(Exception):
|
||||
"""Error in hub client operations."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistentSession:
|
||||
"""A persistent container session with an active MCP connection.
|
||||
|
||||
Keeps a Docker container running between tool calls to allow
|
||||
stateful interactions (e.g., radare2 analysis, long-running fuzzing).
|
||||
|
||||
"""
|
||||
|
||||
#: Server name this session belongs to.
|
||||
server_name: str
|
||||
|
||||
#: Docker container name.
|
||||
container_name: str
|
||||
|
||||
#: Underlying process (docker run).
|
||||
process: Process
|
||||
|
||||
#: Stream reader (process stdout).
|
||||
reader: asyncio.StreamReader
|
||||
|
||||
#: Stream writer (process stdin).
|
||||
writer: asyncio.StreamWriter
|
||||
|
||||
#: Whether the MCP session has been initialized.
|
||||
initialized: bool = False
|
||||
|
||||
#: Lock to serialise concurrent requests on the same session.
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
#: When the session was started.
|
||||
started_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
#: Monotonic counter for JSON-RPC request IDs.
|
||||
request_id: int = 0
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
"""Check if the underlying process is still running."""
|
||||
return self.process.returncode is None
|
||||
|
||||
|
||||
class HubClient:
|
||||
"""Client for communicating with MCP hub servers.
|
||||
|
||||
Supports stdio (via docker/command) and SSE transports.
|
||||
Uses the MCP protocol for tool discovery and execution.
|
||||
|
||||
"""
|
||||
|
||||
#: Default timeout for operations.
|
||||
DEFAULT_TIMEOUT: int = 30
|
||||
|
||||
def __init__(self, timeout: int = DEFAULT_TIMEOUT) -> None:
|
||||
"""Initialize the hub client.
|
||||
|
||||
:param timeout: Default timeout for operations in seconds.
|
||||
|
||||
"""
|
||||
self._timeout = timeout
|
||||
self._persistent_sessions: dict[str, PersistentSession] = {}
|
||||
self._request_id: int = 0
|
||||
|
||||
async def discover_tools(self, server: HubServer) -> list[HubTool]:
|
||||
"""Discover tools from a hub server.
|
||||
|
||||
Connects to the server, calls list_tools(), and returns
|
||||
parsed HubTool instances.
|
||||
|
||||
:param server: Hub server to discover tools from.
|
||||
:returns: List of discovered tools.
|
||||
:raises HubClientError: If discovery fails.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
config = server.config
|
||||
|
||||
logger.info("Discovering tools", server=config.name, type=config.type.value)
|
||||
|
||||
try:
|
||||
async with self._connect(config) as (reader, writer):
|
||||
# Initialise MCP session (skip for persistent — already done)
|
||||
if not self._persistent_sessions.get(config.name):
|
||||
await self._initialize_session(reader, writer, config.name)
|
||||
|
||||
# List tools
|
||||
tools_data = await self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"tools/list",
|
||||
{},
|
||||
)
|
||||
|
||||
# Parse tools
|
||||
tools = []
|
||||
for tool_data in tools_data.get("tools", []):
|
||||
tool = HubTool.from_mcp_tool(
|
||||
server_name=config.name,
|
||||
name=tool_data["name"],
|
||||
description=tool_data.get("description"),
|
||||
input_schema=tool_data.get("inputSchema", {}),
|
||||
)
|
||||
tools.append(tool)
|
||||
|
||||
logger.info(
|
||||
"Discovered tools",
|
||||
server=config.name,
|
||||
count=len(tools),
|
||||
)
|
||||
return tools
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Tool discovery failed",
|
||||
server=config.name,
|
||||
error=str(e),
|
||||
)
|
||||
raise HubClientError(f"Discovery failed for {config.name}: {e}") from e
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
server: HubServer,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
extra_volumes: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a tool on a hub server.
|
||||
|
||||
:param server: Hub server to execute on.
|
||||
:param tool_name: Name of the tool to execute.
|
||||
:param arguments: Tool arguments.
|
||||
:param timeout: Execution timeout (uses default if None).
|
||||
:param extra_volumes: Additional Docker volume mounts to inject.
|
||||
:returns: Tool execution result.
|
||||
:raises HubClientError: If execution fails.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
config = server.config
|
||||
exec_timeout = timeout or config.timeout or self._timeout
|
||||
|
||||
logger.info(
|
||||
"Executing hub tool",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._connect(config, extra_volumes=extra_volumes) as (reader, writer):
|
||||
# Initialise MCP session (skip for persistent — already done)
|
||||
if not self._persistent_sessions.get(config.name):
|
||||
await self._initialize_session(reader, writer, config.name)
|
||||
|
||||
# Call tool
|
||||
result = await asyncio.wait_for(
|
||||
self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"tools/call",
|
||||
{"name": tool_name, "arguments": arguments},
|
||||
),
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Tool execution completed",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
)
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.error(
|
||||
"Tool execution timed out",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
timeout=exec_timeout,
|
||||
)
|
||||
raise HubClientError(
|
||||
f"Execution timed out for {config.name}:{tool_name}"
|
||||
) from e
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Tool execution failed",
|
||||
server=config.name,
|
||||
tool=tool_name,
|
||||
error=str(e),
|
||||
)
|
||||
raise HubClientError(
|
||||
f"Execution failed for {config.name}:{tool_name}: {e}"
|
||||
) from e
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
extra_volumes: list[str] | None = None,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to an MCP server.
|
||||
|
||||
If a persistent session exists for this server, reuse it (with a lock
|
||||
to serialise concurrent requests). Otherwise, fall through to the
|
||||
ephemeral per-call connection logic.
|
||||
|
||||
:param config: Server configuration.
|
||||
:param extra_volumes: Additional Docker volume mounts to inject.
|
||||
:yields: Tuple of (reader, writer) for communication.
|
||||
|
||||
"""
|
||||
# Check for active persistent session
|
||||
session = self._persistent_sessions.get(config.name)
|
||||
if session and session.initialized and session.alive:
|
||||
async with session.lock:
|
||||
yield session.reader, session.writer # type: ignore[misc]
|
||||
return
|
||||
|
||||
# Ephemeral connection (original behaviour)
|
||||
if config.type == HubServerType.DOCKER:
|
||||
async with self._connect_docker(config, extra_volumes=extra_volumes) as streams:
|
||||
yield streams
|
||||
elif config.type == HubServerType.COMMAND:
|
||||
async with self._connect_command(config) as streams:
|
||||
yield streams
|
||||
elif config.type == HubServerType.SSE:
|
||||
async with self._connect_sse(config) as streams:
|
||||
yield streams
|
||||
else:
|
||||
msg = f"Unsupported server type: {config.type}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_docker(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
extra_volumes: list[str] | None = None,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to a Docker-based MCP server.
|
||||
|
||||
:param config: Server configuration with image name.
|
||||
:param extra_volumes: Additional volume mounts to inject (e.g. project assets).
|
||||
:yields: Tuple of (reader, writer) for stdio communication.
|
||||
|
||||
"""
|
||||
if not config.image:
|
||||
msg = f"Docker image not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Build docker command
|
||||
cmd = ["docker", "run", "-i", "--rm"]
|
||||
|
||||
# Add capabilities
|
||||
for cap in config.capabilities:
|
||||
cmd.extend(["--cap-add", cap])
|
||||
|
||||
# Add volumes from server config
|
||||
for volume in config.volumes:
|
||||
cmd.extend(["-v", os.path.expanduser(volume)])
|
||||
|
||||
# Add extra volumes (e.g. project assets injected at runtime)
|
||||
for volume in (extra_volumes or []):
|
||||
cmd.extend(["-v", os.path.expanduser(volume)])
|
||||
|
||||
# Add environment variables
|
||||
for key, value in config.environment.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
cmd.append(config.image)
|
||||
|
||||
# Use 4 MB buffer to handle large tool responses (YARA rulesets, trivy output, etc.)
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
try:
|
||||
if process.stdin is None or process.stdout is None:
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Create asyncio streams from process pipes
|
||||
reader = process.stdout
|
||||
writer = process.stdin
|
||||
|
||||
yield reader, writer # type: ignore[misc]
|
||||
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_command(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to a command-based MCP server.
|
||||
|
||||
:param config: Server configuration with command.
|
||||
:yields: Tuple of (reader, writer) for stdio communication.
|
||||
|
||||
"""
|
||||
if not config.command:
|
||||
msg = f"Command not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
# Set up environment
|
||||
env = dict(config.environment) if config.environment else None
|
||||
|
||||
# Use 4 MB buffer to handle large tool responses
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*config.command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
try:
|
||||
if process.stdin is None or process.stdout is None:
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
reader = process.stdout
|
||||
writer = process.stdin
|
||||
|
||||
yield reader, writer # type: ignore[misc]
|
||||
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _connect_sse(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:
|
||||
"""Connect to an SSE-based MCP server.
|
||||
|
||||
:param config: Server configuration with URL.
|
||||
:yields: Tuple of (reader, writer) for SSE communication.
|
||||
|
||||
"""
|
||||
# SSE support requires additional dependencies
|
||||
# For now, raise not implemented
|
||||
msg = "SSE transport not yet implemented"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
async def _initialize_session(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
server_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Initialize MCP session with the server.
|
||||
|
||||
:param reader: Stream reader.
|
||||
:param writer: Stream writer.
|
||||
:param server_name: Server name for logging.
|
||||
:returns: Server capabilities.
|
||||
|
||||
"""
|
||||
# Send initialize request
|
||||
result = await self._call_method(
|
||||
reader,
|
||||
writer,
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "secpipe-hub",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Send initialized notification
|
||||
await self._send_notification(reader, writer, "notifications/initialized", {})
|
||||
|
||||
return result
|
||||
|
||||
async def _call_method(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Call an MCP method.
|
||||
|
||||
:param reader: Stream reader.
|
||||
:param writer: Stream writer.
|
||||
:param method: Method name.
|
||||
:param params: Method parameters.
|
||||
:returns: Method result.
|
||||
|
||||
"""
|
||||
# Create JSON-RPC request with unique ID
|
||||
self._request_id += 1
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": self._request_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
# Send request
|
||||
request_line = json.dumps(request) + "\n"
|
||||
writer.write(request_line.encode())
|
||||
await writer.drain()
|
||||
|
||||
# Read response
|
||||
response_line = await asyncio.wait_for(
|
||||
reader.readline(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
if not response_line:
|
||||
msg = "Empty response from server"
|
||||
raise HubClientError(msg)
|
||||
|
||||
response = json.loads(response_line.decode())
|
||||
|
||||
if "error" in response:
|
||||
error = response["error"]
|
||||
msg = f"MCP error: {error.get('message', 'Unknown error')}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
result = response.get("result", {})
|
||||
|
||||
# Check for tool-level errors in content items
|
||||
for item in result.get("content", []):
|
||||
if item.get("isError", False):
|
||||
error_text = item.get("text", "unknown error")
|
||||
msg = f"Tool returned error: {error_text}"
|
||||
raise HubClientError(msg)
|
||||
|
||||
return result
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
method: str,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send an MCP notification (no response expected).
|
||||
|
||||
:param reader: Stream reader (unused but kept for consistency).
|
||||
:param writer: Stream writer.
|
||||
:param method: Notification method name.
|
||||
:param params: Notification parameters.
|
||||
|
||||
"""
|
||||
# Create JSON-RPC notification (no id)
|
||||
notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
notification_line = json.dumps(notification) + "\n"
|
||||
writer.write(notification_line.encode())
|
||||
await writer.drain()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistent session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_persistent_session(
|
||||
self,
|
||||
config: HubServerConfig,
|
||||
extra_volumes: list[str] | None = None,
|
||||
) -> PersistentSession:
|
||||
"""Start a persistent Docker container and initialise MCP session.
|
||||
|
||||
The container stays running until :meth:`stop_persistent_session` is
|
||||
called, allowing multiple tool calls on the same session.
|
||||
|
||||
:param config: Server configuration (must be Docker type).
|
||||
:param extra_volumes: Additional host:container volume mounts to inject.
|
||||
:returns: The created persistent session.
|
||||
:raises HubClientError: If the container cannot be started.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
if config.name in self._persistent_sessions:
|
||||
session = self._persistent_sessions[config.name]
|
||||
if session.alive:
|
||||
logger.info("Persistent session already running", server=config.name)
|
||||
return session
|
||||
# Dead session — clean up and restart
|
||||
await self._cleanup_session(config.name)
|
||||
|
||||
if config.type != HubServerType.DOCKER:
|
||||
msg = f"Persistent mode only supports Docker servers (got {config.type.value})"
|
||||
raise HubClientError(msg)
|
||||
|
||||
if not config.image:
|
||||
msg = f"Docker image not specified for server '{config.name}'"
|
||||
raise HubClientError(msg)
|
||||
|
||||
container_name = f"secpipe-{config.name}"
|
||||
|
||||
# Remove stale container with same name if it exists
|
||||
try:
|
||||
rm_proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "rm", "-f", container_name,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
await rm_proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build docker run command (no --rm, with --name)
|
||||
cmd = ["docker", "run", "-i", "--name", container_name]
|
||||
|
||||
for cap in config.capabilities:
|
||||
cmd.extend(["--cap-add", cap])
|
||||
|
||||
for volume in config.volumes:
|
||||
cmd.extend(["-v", os.path.expanduser(volume)])
|
||||
|
||||
for extra_vol in (extra_volumes or []):
|
||||
cmd.extend(["-v", extra_vol])
|
||||
|
||||
for key, value in config.environment.items():
|
||||
cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
cmd.append(config.image)
|
||||
|
||||
_STREAM_LIMIT = 4 * 1024 * 1024
|
||||
|
||||
logger.info(
|
||||
"Starting persistent container",
|
||||
server=config.name,
|
||||
container=container_name,
|
||||
image=config.image,
|
||||
)
|
||||
|
||||
process: Process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
limit=_STREAM_LIMIT,
|
||||
)
|
||||
|
||||
if process.stdin is None or process.stdout is None:
|
||||
process.terminate()
|
||||
msg = "Failed to get process streams"
|
||||
raise HubClientError(msg)
|
||||
|
||||
session = PersistentSession(
|
||||
server_name=config.name,
|
||||
container_name=container_name,
|
||||
process=process,
|
||||
reader=process.stdout,
|
||||
writer=process.stdin,
|
||||
)
|
||||
|
||||
# Initialise MCP session
|
||||
try:
|
||||
await self._initialize_session(
|
||||
session.reader, # type: ignore[arg-type]
|
||||
session.writer, # type: ignore[arg-type]
|
||||
config.name,
|
||||
)
|
||||
session.initialized = True
|
||||
except Exception as e:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
msg = f"Failed to initialise MCP session for {config.name}: {e}"
|
||||
raise HubClientError(msg) from e
|
||||
|
||||
self._persistent_sessions[config.name] = session
|
||||
|
||||
logger.info(
|
||||
"Persistent session started",
|
||||
server=config.name,
|
||||
container=container_name,
|
||||
)
|
||||
return session
|
||||
|
||||
async def stop_persistent_session(self, server_name: str) -> bool:
|
||||
"""Stop a persistent container session.
|
||||
|
||||
:param server_name: Name of the server whose session to stop.
|
||||
:returns: True if a session was stopped, False if none found.
|
||||
|
||||
"""
|
||||
return await self._cleanup_session(server_name)
|
||||
|
||||
def get_persistent_session(self, server_name: str) -> PersistentSession | None:
|
||||
"""Get a persistent session by server name.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: The session if running, None otherwise.
|
||||
|
||||
"""
|
||||
session = self._persistent_sessions.get(server_name)
|
||||
if session and not session.alive:
|
||||
# Mark dead session — don't remove here to avoid async issues
|
||||
return None
|
||||
return session
|
||||
|
||||
def list_persistent_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all persistent sessions with their status.
|
||||
|
||||
:returns: List of session info dictionaries.
|
||||
|
||||
"""
|
||||
sessions = []
|
||||
for name, session in self._persistent_sessions.items():
|
||||
sessions.append({
|
||||
"server_name": name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"uptime_seconds": int(
|
||||
(datetime.now(tz=timezone.utc) - session.started_at).total_seconds()
|
||||
),
|
||||
})
|
||||
return sessions
|
||||
|
||||
async def stop_all_persistent_sessions(self) -> int:
|
||||
"""Stop all persistent sessions.
|
||||
|
||||
:returns: Number of sessions stopped.
|
||||
|
||||
"""
|
||||
names = list(self._persistent_sessions.keys())
|
||||
count = 0
|
||||
for name in names:
|
||||
if await self._cleanup_session(name):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def _cleanup_session(self, server_name: str) -> bool:
|
||||
"""Clean up a persistent session (terminate process, remove container).
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: True if cleaned up, False if not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
session = self._persistent_sessions.pop(server_name, None)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
logger.info("Stopping persistent session", server=server_name)
|
||||
|
||||
# Terminate process
|
||||
if session.alive:
|
||||
session.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(session.process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
session.process.kill()
|
||||
await session.process.wait()
|
||||
|
||||
# Remove Docker container
|
||||
try:
|
||||
rm_proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "rm", "-f", session.container_name,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
await rm_proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"Persistent session stopped",
|
||||
server=server_name,
|
||||
container=session.container_name,
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,627 @@
|
||||
"""Hub executor for managing MCP server lifecycle and tool execution.
|
||||
|
||||
This module provides a high-level interface for:
|
||||
- Discovering tools from all registered hub servers
|
||||
- Executing tools with proper error handling
|
||||
- Managing the lifecycle of hub operations
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from secpipe_common.hub.client import HubClient, HubClientError, PersistentSession
|
||||
from secpipe_common.hub.models import HubServer, HubServerConfig, HubTool
|
||||
from secpipe_common.hub.registry import HubRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubExecutionResult:
|
||||
"""Result of a hub tool execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
success: bool,
|
||||
server_name: str,
|
||||
tool_name: str,
|
||||
result: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize execution result.
|
||||
|
||||
:param success: Whether execution succeeded.
|
||||
:param server_name: Name of the hub server.
|
||||
:param tool_name: Name of the executed tool.
|
||||
:param result: Tool execution result data.
|
||||
:param error: Error message if execution failed.
|
||||
|
||||
"""
|
||||
self.success = success
|
||||
self.server_name = server_name
|
||||
self.tool_name = tool_name
|
||||
self.result = result or {}
|
||||
self.error = error
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get full tool identifier."""
|
||||
return f"hub:{self.server_name}:{self.tool_name}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary.
|
||||
|
||||
:returns: Dictionary representation.
|
||||
|
||||
"""
|
||||
return {
|
||||
"success": self.success,
|
||||
"identifier": self.identifier,
|
||||
"server": self.server_name,
|
||||
"tool": self.tool_name,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class HubExecutor:
|
||||
"""Executor for hub server operations.
|
||||
|
||||
Provides high-level methods for discovering and executing
|
||||
tools from hub servers.
|
||||
|
||||
"""
|
||||
|
||||
#: Hub registry instance.
|
||||
_registry: HubRegistry
|
||||
|
||||
#: MCP client instance.
|
||||
_client: HubClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Path | None = None,
|
||||
timeout: int = 300,
|
||||
) -> None:
|
||||
"""Initialize the hub executor.
|
||||
|
||||
:param config_path: Path to hub-servers.json config file.
|
||||
:param timeout: Default timeout for tool execution.
|
||||
|
||||
"""
|
||||
self._registry = HubRegistry(config_path)
|
||||
self._client = HubClient(timeout=timeout)
|
||||
self._continuous_sessions: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@property
|
||||
def registry(self) -> HubRegistry:
|
||||
"""Get the hub registry.
|
||||
|
||||
:returns: Hub registry instance.
|
||||
|
||||
"""
|
||||
return self._registry
|
||||
|
||||
def add_server(self, config: HubServerConfig) -> HubServer:
|
||||
"""Add a server to the registry.
|
||||
|
||||
:param config: Server configuration.
|
||||
:returns: Created HubServer instance.
|
||||
|
||||
"""
|
||||
return self._registry.add_server(config)
|
||||
|
||||
async def discover_all_tools(self) -> dict[str, list[HubTool]]:
|
||||
"""Discover tools from all enabled servers.
|
||||
|
||||
:returns: Dict mapping server names to lists of discovered tools.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
results: dict[str, list[HubTool]] = {}
|
||||
|
||||
for server in self._registry.enabled_servers:
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server.name, tools)
|
||||
results[server.name] = tools
|
||||
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Failed to discover tools",
|
||||
server=server.name,
|
||||
error=str(e),
|
||||
)
|
||||
self._registry.update_server_tools(server.name, [], error=str(e))
|
||||
results[server.name] = []
|
||||
|
||||
return results
|
||||
|
||||
async def discover_server_tools(self, server_name: str) -> list[HubTool]:
|
||||
"""Discover tools from a specific server.
|
||||
|
||||
:param server_name: Name of the server.
|
||||
:returns: List of discovered tools.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
return tools
|
||||
|
||||
except HubClientError as e:
|
||||
self._registry.update_server_tools(server_name, [], error=str(e))
|
||||
raise
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
identifier: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
extra_volumes: list[str] | None = None,
|
||||
) -> HubExecutionResult:
|
||||
"""Execute a hub tool.
|
||||
|
||||
:param identifier: Tool identifier (hub:server:tool or server:tool).
|
||||
:param arguments: Tool arguments.
|
||||
:param timeout: Execution timeout.
|
||||
:param extra_volumes: Additional Docker volume mounts to inject.
|
||||
:returns: Execution result.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
arguments = arguments or {}
|
||||
|
||||
# Parse identifier and find tool
|
||||
server, tool = self._registry.find_tool(identifier)
|
||||
|
||||
if not server or not tool:
|
||||
# Try to parse as server:tool and discover
|
||||
parts = identifier.replace("hub:", "").split(":")
|
||||
if len(parts) == 2: # noqa: PLR2004
|
||||
server_name, tool_name = parts
|
||||
server = self._registry.get_server(server_name)
|
||||
|
||||
if server and not server.discovered:
|
||||
# Try to discover tools first
|
||||
try:
|
||||
await self.discover_server_tools(server_name)
|
||||
tool = server.get_tool(tool_name)
|
||||
except HubClientError:
|
||||
pass
|
||||
|
||||
if server and not tool:
|
||||
# Tool not found, but server exists - try to execute anyway
|
||||
# The server might have the tool even if discovery failed
|
||||
tool_name_to_use = tool_name
|
||||
else:
|
||||
tool_name_to_use = tool.name if tool else ""
|
||||
|
||||
if not server:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server_name,
|
||||
tool_name=tool_name,
|
||||
error=f"Server '{server_name}' not found",
|
||||
)
|
||||
|
||||
# Execute even if tool wasn't discovered (server might still have it)
|
||||
try:
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
tool_name_to_use or tool_name,
|
||||
arguments,
|
||||
timeout=timeout,
|
||||
extra_volumes=extra_volumes,
|
||||
)
|
||||
return HubExecutionResult(
|
||||
success=True,
|
||||
server_name=server.name,
|
||||
tool_name=tool_name_to_use or tool_name,
|
||||
result=result,
|
||||
)
|
||||
except HubClientError as e:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server.name,
|
||||
tool_name=tool_name_to_use or tool_name,
|
||||
error=str(e),
|
||||
)
|
||||
else:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name="unknown",
|
||||
tool_name=identifier,
|
||||
error=f"Invalid tool identifier: {identifier}",
|
||||
)
|
||||
|
||||
# Execute the tool
|
||||
logger.info(
|
||||
"Executing hub tool",
|
||||
server=server.name,
|
||||
tool=tool.name,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
tool.name,
|
||||
arguments,
|
||||
timeout=timeout,
|
||||
extra_volumes=extra_volumes,
|
||||
)
|
||||
return HubExecutionResult(
|
||||
success=True,
|
||||
server_name=server.name,
|
||||
tool_name=tool.name,
|
||||
result=result,
|
||||
)
|
||||
|
||||
except HubClientError as e:
|
||||
return HubExecutionResult(
|
||||
success=False,
|
||||
server_name=server.name,
|
||||
tool_name=tool.name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def list_servers(self) -> list[dict[str, Any]]:
|
||||
"""List all registered servers with their status.
|
||||
|
||||
:returns: List of server info dicts.
|
||||
|
||||
"""
|
||||
servers = []
|
||||
for server in self._registry.servers:
|
||||
session = self._client.get_persistent_session(server.name)
|
||||
servers.append({
|
||||
"name": server.name,
|
||||
"identifier": server.identifier,
|
||||
"type": server.config.type.value,
|
||||
"enabled": server.config.enabled,
|
||||
"category": server.config.category,
|
||||
"description": server.config.description,
|
||||
"persistent": server.config.persistent,
|
||||
"persistent_session_active": session is not None and session.alive,
|
||||
"discovered": server.discovered,
|
||||
"tool_count": len(server.tools),
|
||||
"error": server.discovery_error,
|
||||
})
|
||||
return servers
|
||||
|
||||
def list_tools(self) -> list[dict[str, Any]]:
|
||||
"""List all discovered tools.
|
||||
|
||||
:returns: List of tool info dicts.
|
||||
|
||||
"""
|
||||
tools = []
|
||||
for tool in self._registry.get_all_tools():
|
||||
tools.append({
|
||||
"identifier": tool.identifier,
|
||||
"name": tool.name,
|
||||
"server": tool.server_name,
|
||||
"description": tool.description,
|
||||
"parameters": [p.model_dump() for p in tool.parameters],
|
||||
})
|
||||
return tools
|
||||
|
||||
def get_tool_schema(self, identifier: str) -> dict[str, Any] | None:
|
||||
"""Get the JSON Schema for a tool's input.
|
||||
|
||||
:param identifier: Tool identifier.
|
||||
:returns: JSON Schema dict or None if not found.
|
||||
|
||||
"""
|
||||
_, tool = self._registry.find_tool(identifier)
|
||||
if tool:
|
||||
return tool.input_schema
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistent session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_persistent_server(self, server_name: str, extra_volumes: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Start a persistent container session for a server.
|
||||
|
||||
The container stays running between tool calls, allowing stateful
|
||||
interactions (e.g., radare2 sessions, long-running fuzzing).
|
||||
|
||||
:param server_name: Name of the hub server to start.
|
||||
:param extra_volumes: Additional host:container volume mounts to inject.
|
||||
:returns: Session status dictionary.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
session = await self._client.start_persistent_session(server.config, extra_volumes=extra_volumes)
|
||||
|
||||
# Auto-discover tools on the new session
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Tool discovery failed on persistent session",
|
||||
server=server_name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Include discovered tools in the result so agent knows what's available
|
||||
discovered_tools = []
|
||||
server_obj = self._registry.get_server(server_name)
|
||||
if server_obj:
|
||||
for tool in server_obj.tools:
|
||||
discovered_tools.append({
|
||||
"identifier": tool.identifier,
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
})
|
||||
|
||||
return {
|
||||
"server_name": session.server_name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"tools": discovered_tools,
|
||||
"tool_count": len(discovered_tools),
|
||||
}
|
||||
|
||||
async def stop_persistent_server(self, server_name: str) -> bool:
|
||||
"""Stop a persistent container session.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: True if a session was stopped.
|
||||
|
||||
"""
|
||||
return await self._client.stop_persistent_session(server_name)
|
||||
|
||||
def get_persistent_status(self, server_name: str) -> dict[str, Any] | None:
|
||||
"""Get status of a persistent session.
|
||||
|
||||
:param server_name: Server name.
|
||||
:returns: Status dict or None if no session.
|
||||
|
||||
"""
|
||||
session = self._client.get_persistent_session(server_name)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
return {
|
||||
"server_name": session.server_name,
|
||||
"container_name": session.container_name,
|
||||
"alive": session.alive,
|
||||
"initialized": session.initialized,
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"uptime_seconds": int(
|
||||
(datetime.now(tz=timezone.utc) - session.started_at).total_seconds()
|
||||
),
|
||||
}
|
||||
|
||||
def list_persistent_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all persistent sessions.
|
||||
|
||||
:returns: List of session status dicts.
|
||||
|
||||
"""
|
||||
return self._client.list_persistent_sessions()
|
||||
|
||||
async def stop_all_persistent_servers(self) -> int:
|
||||
"""Stop all persistent sessions.
|
||||
|
||||
:returns: Number of sessions stopped.
|
||||
|
||||
"""
|
||||
return await self._client.stop_all_persistent_sessions()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Continuous session management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start_continuous_tool(
|
||||
self,
|
||||
server_name: str,
|
||||
start_tool: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Start a continuous hub tool session.
|
||||
|
||||
Ensures a persistent container is running, then calls the start tool
|
||||
(e.g., ``cargo_fuzz_start``) which returns a session_id. Tracks the
|
||||
session for subsequent status/stop calls.
|
||||
|
||||
:param server_name: Hub server name.
|
||||
:param start_tool: Name of the start tool on the server.
|
||||
:param arguments: Arguments for the start tool.
|
||||
:returns: Start result including session_id.
|
||||
:raises ValueError: If server not found.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
server = self._registry.get_server(server_name)
|
||||
if not server:
|
||||
msg = f"Server '{server_name}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Ensure persistent session is running
|
||||
persistent = self._client.get_persistent_session(server_name)
|
||||
if not persistent or not persistent.alive:
|
||||
logger.info(
|
||||
"Auto-starting persistent session for continuous tool",
|
||||
server=server_name,
|
||||
)
|
||||
await self._client.start_persistent_session(server.config)
|
||||
# Discover tools on the new session
|
||||
try:
|
||||
tools = await self._client.discover_tools(server)
|
||||
self._registry.update_server_tools(server_name, tools)
|
||||
except HubClientError as e:
|
||||
logger.warning(
|
||||
"Tool discovery failed on persistent session",
|
||||
server=server_name,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# Call the start tool
|
||||
result = await self._client.execute_tool(
|
||||
server, start_tool, arguments,
|
||||
)
|
||||
|
||||
# Extract session_id from result
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
start_result = json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
start_result = result
|
||||
|
||||
session_id = start_result.get("session_id", "")
|
||||
|
||||
if session_id:
|
||||
from datetime import datetime, timezone # noqa: PLC0415
|
||||
|
||||
self._continuous_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"server_name": server_name,
|
||||
"start_tool": start_tool,
|
||||
"status_tool": start_tool.replace("_start", "_status"),
|
||||
"stop_tool": start_tool.replace("_start", "_stop"),
|
||||
"started_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
return start_result
|
||||
|
||||
async def get_continuous_tool_status(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Get status of a continuous hub tool session.
|
||||
|
||||
:param session_id: Session ID from start_continuous_tool.
|
||||
:returns: Status dict from the hub server's status tool.
|
||||
:raises ValueError: If session not found.
|
||||
|
||||
"""
|
||||
session_info = self._continuous_sessions.get(session_id)
|
||||
if not session_info:
|
||||
msg = f"Unknown continuous session: {session_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = self._registry.get_server(session_info["server_name"])
|
||||
if not server:
|
||||
msg = f"Server '{session_info['server_name']}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
session_info["status_tool"],
|
||||
{"session_id": session_id},
|
||||
)
|
||||
|
||||
# Parse the text content
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
return json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
return result
|
||||
|
||||
async def stop_continuous_tool(
|
||||
self,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Stop a continuous hub tool session.
|
||||
|
||||
:param session_id: Session ID to stop.
|
||||
:returns: Final results from the hub server's stop tool.
|
||||
:raises ValueError: If session not found.
|
||||
|
||||
"""
|
||||
session_info = self._continuous_sessions.get(session_id)
|
||||
if not session_info:
|
||||
msg = f"Unknown continuous session: {session_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = self._registry.get_server(session_info["server_name"])
|
||||
if not server:
|
||||
msg = f"Server '{session_info['server_name']}' not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
result = await self._client.execute_tool(
|
||||
server,
|
||||
session_info["stop_tool"],
|
||||
{"session_id": session_id},
|
||||
)
|
||||
|
||||
# Parse the text content
|
||||
content_text = ""
|
||||
for item in result.get("content", []):
|
||||
if item.get("type") == "text":
|
||||
content_text = item.get("text", "")
|
||||
break
|
||||
|
||||
import json # noqa: PLC0415
|
||||
|
||||
try:
|
||||
stop_result = json.loads(content_text) if content_text else result
|
||||
except json.JSONDecodeError:
|
||||
stop_result = result
|
||||
|
||||
# Update session tracking
|
||||
session_info["status"] = "stopped"
|
||||
|
||||
return stop_result
|
||||
|
||||
def list_continuous_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List all tracked continuous sessions.
|
||||
|
||||
:returns: List of continuous session info dicts.
|
||||
|
||||
"""
|
||||
return list(self._continuous_sessions.values())
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Data models for SecPipe Hub.
|
||||
|
||||
This module defines the Pydantic models used to represent MCP servers
|
||||
and their tools in the hub registry.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HubServerType(str, Enum):
|
||||
"""Type of MCP server connection."""
|
||||
|
||||
#: Run as Docker container with stdio transport.
|
||||
DOCKER = "docker"
|
||||
#: Run as local command/process with stdio transport.
|
||||
COMMAND = "command"
|
||||
#: Connect via Server-Sent Events (HTTP).
|
||||
SSE = "sse"
|
||||
|
||||
|
||||
class HubServerConfig(BaseModel):
|
||||
"""Configuration for an MCP server in the hub.
|
||||
|
||||
This defines how to connect to an MCP server, not what tools it provides.
|
||||
Tools are discovered dynamically at runtime.
|
||||
|
||||
"""
|
||||
|
||||
#: Unique identifier for this server (e.g., "nmap", "nuclei").
|
||||
name: str = Field(description="Unique server identifier")
|
||||
|
||||
#: Human-readable description of the server.
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable description",
|
||||
)
|
||||
|
||||
#: Type of connection to use.
|
||||
type: HubServerType = Field(description="Connection type")
|
||||
|
||||
#: Docker image name (for type=docker).
|
||||
image: str | None = Field(
|
||||
default=None,
|
||||
description="Docker image name (for docker type)",
|
||||
)
|
||||
|
||||
#: Command to run (for type=command).
|
||||
command: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Command and args (for command type)",
|
||||
)
|
||||
|
||||
#: URL endpoint (for type=sse).
|
||||
url: str | None = Field(
|
||||
default=None,
|
||||
description="SSE endpoint URL (for sse type)",
|
||||
)
|
||||
|
||||
#: Environment variables to pass to the server.
|
||||
environment: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Environment variables",
|
||||
)
|
||||
|
||||
#: Docker capabilities to add (e.g., ["NET_RAW"] for nmap).
|
||||
capabilities: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Docker capabilities to add",
|
||||
)
|
||||
|
||||
#: Volume mounts for Docker (e.g., ["/host/path:/container/path:ro"]).
|
||||
volumes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Docker volume mounts",
|
||||
)
|
||||
|
||||
#: Whether this server is enabled.
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether server is enabled",
|
||||
)
|
||||
|
||||
#: Category for grouping (e.g., "reconnaissance", "web-security").
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
description="Category for grouping servers",
|
||||
)
|
||||
|
||||
#: Per-server timeout override in seconds (None = use default_timeout).
|
||||
timeout: int | None = Field(
|
||||
default=None,
|
||||
description="Per-server execution timeout override in seconds",
|
||||
)
|
||||
|
||||
#: Whether to use persistent container mode (keep container running between calls).
|
||||
persistent: bool = Field(
|
||||
default=False,
|
||||
description="Keep container running between tool calls for stateful interactions",
|
||||
)
|
||||
|
||||
|
||||
class HubToolParameter(BaseModel):
|
||||
"""A parameter for an MCP tool.
|
||||
|
||||
Parsed from the tool's JSON Schema inputSchema.
|
||||
|
||||
"""
|
||||
|
||||
#: Parameter name.
|
||||
name: str
|
||||
|
||||
#: Parameter type (string, integer, boolean, array, object).
|
||||
type: str
|
||||
|
||||
#: Human-readable description.
|
||||
description: str | None = None
|
||||
|
||||
#: Whether this parameter is required.
|
||||
required: bool = False
|
||||
|
||||
#: Default value if any.
|
||||
default: Any = None
|
||||
|
||||
#: Enum values if constrained.
|
||||
enum: list[Any] | None = None
|
||||
|
||||
|
||||
class HubTool(BaseModel):
|
||||
"""An MCP tool discovered from a hub server.
|
||||
|
||||
This is populated by calling `list_tools()` on the MCP server.
|
||||
|
||||
"""
|
||||
|
||||
#: Tool name as defined by the MCP server.
|
||||
name: str = Field(description="Tool name from MCP server")
|
||||
|
||||
#: Human-readable description.
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Tool description",
|
||||
)
|
||||
|
||||
#: Name of the hub server this tool belongs to.
|
||||
server_name: str = Field(description="Parent server name")
|
||||
|
||||
#: Parsed parameters from inputSchema.
|
||||
parameters: list[HubToolParameter] = Field(
|
||||
default_factory=list,
|
||||
description="Tool parameters",
|
||||
)
|
||||
|
||||
#: Raw JSON Schema for the tool input.
|
||||
input_schema: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Raw JSON Schema from MCP",
|
||||
)
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get the full tool identifier (hub:server:tool)."""
|
||||
return f"hub:{self.server_name}:{self.name}"
|
||||
|
||||
@classmethod
|
||||
def from_mcp_tool(
|
||||
cls,
|
||||
server_name: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
input_schema: dict[str, Any],
|
||||
) -> HubTool:
|
||||
"""Create a HubTool from MCP tool metadata.
|
||||
|
||||
:param server_name: Name of the parent hub server.
|
||||
:param name: Tool name.
|
||||
:param description: Tool description.
|
||||
:param input_schema: JSON Schema for tool input.
|
||||
:returns: HubTool instance.
|
||||
|
||||
"""
|
||||
parameters = cls._parse_parameters(input_schema)
|
||||
return cls(
|
||||
name=name,
|
||||
description=description,
|
||||
server_name=server_name,
|
||||
parameters=parameters,
|
||||
input_schema=input_schema,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_parameters(schema: dict[str, Any]) -> list[HubToolParameter]:
|
||||
"""Parse parameters from JSON Schema.
|
||||
|
||||
:param schema: JSON Schema dict.
|
||||
:returns: List of parsed parameters.
|
||||
|
||||
"""
|
||||
parameters: list[HubToolParameter] = []
|
||||
properties = schema.get("properties", {})
|
||||
required_params = set(schema.get("required", []))
|
||||
|
||||
for name, prop in properties.items():
|
||||
param = HubToolParameter(
|
||||
name=name,
|
||||
type=prop.get("type", "string"),
|
||||
description=prop.get("description"),
|
||||
required=name in required_params,
|
||||
default=prop.get("default"),
|
||||
enum=prop.get("enum"),
|
||||
)
|
||||
parameters.append(param)
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
class HubServer(BaseModel):
|
||||
"""A hub server with its discovered tools.
|
||||
|
||||
Combines configuration with dynamically discovered tools.
|
||||
|
||||
"""
|
||||
|
||||
#: Server configuration.
|
||||
config: HubServerConfig
|
||||
|
||||
#: Tools discovered from the server (populated at runtime).
|
||||
tools: list[HubTool] = Field(
|
||||
default_factory=list,
|
||||
description="Discovered tools",
|
||||
)
|
||||
|
||||
#: Whether tools have been discovered.
|
||||
discovered: bool = Field(
|
||||
default=False,
|
||||
description="Whether tools have been discovered",
|
||||
)
|
||||
|
||||
#: Error message if discovery failed.
|
||||
discovery_error: str | None = Field(
|
||||
default=None,
|
||||
description="Error message if discovery failed",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get server name."""
|
||||
return self.config.name
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""Get server identifier for module listing."""
|
||||
return f"hub:{self.config.name}"
|
||||
|
||||
def get_tool(self, tool_name: str) -> HubTool | None:
|
||||
"""Get a tool by name.
|
||||
|
||||
:param tool_name: Name of the tool.
|
||||
:returns: HubTool if found, None otherwise.
|
||||
|
||||
"""
|
||||
for tool in self.tools:
|
||||
if tool.name == tool_name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
class HubConfig(BaseModel):
|
||||
"""Configuration for the entire hub.
|
||||
|
||||
Loaded from hub-servers.json or similar config file.
|
||||
|
||||
"""
|
||||
|
||||
#: List of configured servers.
|
||||
servers: list[HubServerConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Configured MCP servers",
|
||||
)
|
||||
|
||||
#: Default timeout for tool execution (seconds).
|
||||
default_timeout: int = Field(
|
||||
default=300,
|
||||
description="Default execution timeout",
|
||||
)
|
||||
|
||||
#: Whether to cache discovered tools.
|
||||
cache_tools: bool = Field(
|
||||
default=True,
|
||||
description="Cache discovered tools",
|
||||
)
|
||||
|
||||
#: Workflow hints indexed by "after:<tool_name>" keys.
|
||||
#: Loaded inline or merged from workflow_hints_file.
|
||||
workflow_hints: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Workflow hints indexed by 'after:<tool_name>'",
|
||||
)
|
||||
|
||||
#: Optional path to an external workflow-hints.json file.
|
||||
#: Relative paths are resolved relative to the hub-config.json location.
|
||||
workflow_hints_file: str | None = Field(
|
||||
default=None,
|
||||
description="Path to an external workflow-hints.json to load and merge",
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Hub registry for managing MCP server configurations.
|
||||
|
||||
The registry loads server configurations from a JSON file and provides
|
||||
methods to access and manage them. It does not hardcode any specific
|
||||
servers or tools - everything is configured by the user.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from secpipe_common.hub.models import (
|
||||
HubConfig,
|
||||
HubServer,
|
||||
HubServerConfig,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structlog logger instance.
|
||||
|
||||
:returns: Configured structlog logger.
|
||||
|
||||
"""
|
||||
from structlog import get_logger # noqa: PLC0415
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class HubRegistry:
|
||||
"""Registry for MCP hub servers.
|
||||
|
||||
Manages the configuration and state of hub servers.
|
||||
Configurations are loaded from a JSON file.
|
||||
|
||||
"""
|
||||
|
||||
#: Loaded hub configuration.
|
||||
_config: HubConfig
|
||||
|
||||
#: Server instances with discovered tools.
|
||||
_servers: dict[str, HubServer]
|
||||
|
||||
#: Path to the configuration file.
|
||||
_config_path: Path | None
|
||||
|
||||
def __init__(self, config_path: Path | str | None = None) -> None:
|
||||
"""Initialize the hub registry.
|
||||
|
||||
:param config_path: Path to hub-servers.json config file.
|
||||
If None, starts with empty configuration.
|
||||
|
||||
"""
|
||||
if config_path is not None:
|
||||
self._config_path = Path(config_path)
|
||||
else:
|
||||
self._config_path = None
|
||||
self._servers = {}
|
||||
self._config = HubConfig()
|
||||
|
||||
if self._config_path and self._config_path.exists():
|
||||
self._load_config(self._config_path)
|
||||
|
||||
def _load_config(self, config_path: Path) -> None:
|
||||
"""Load configuration from JSON file.
|
||||
|
||||
:param config_path: Path to config file.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
try:
|
||||
with config_path.open() as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._config = HubConfig.model_validate(data)
|
||||
|
||||
# Create server instances from config
|
||||
for server_config in self._config.servers:
|
||||
if server_config.enabled:
|
||||
self._servers[server_config.name] = HubServer(
|
||||
config=server_config,
|
||||
)
|
||||
|
||||
# Load and merge external workflow hints file if specified.
|
||||
if self._config.workflow_hints_file:
|
||||
hints_path = Path(self._config.workflow_hints_file)
|
||||
if not hints_path.is_absolute():
|
||||
hints_path = config_path.parent / hints_path
|
||||
if hints_path.exists():
|
||||
try:
|
||||
with hints_path.open() as hf:
|
||||
hints_data = json.load(hf)
|
||||
self._config.workflow_hints.update(hints_data.get("hints", {}))
|
||||
logger.info(
|
||||
"Loaded workflow hints",
|
||||
path=str(hints_path),
|
||||
hints=len(self._config.workflow_hints),
|
||||
)
|
||||
except Exception as hints_err:
|
||||
logger.warning(
|
||||
"Failed to load workflow hints file",
|
||||
path=str(hints_path),
|
||||
error=str(hints_err),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Loaded hub configuration",
|
||||
path=str(config_path),
|
||||
servers=len(self._servers),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to load hub configuration",
|
||||
path=str(config_path),
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload configuration from file."""
|
||||
if self._config_path and self._config_path.exists():
|
||||
self._servers.clear()
|
||||
self._load_config(self._config_path)
|
||||
|
||||
@property
|
||||
def servers(self) -> list[HubServer]:
|
||||
"""Get all registered servers.
|
||||
|
||||
:returns: List of hub servers.
|
||||
|
||||
"""
|
||||
return list(self._servers.values())
|
||||
|
||||
@property
|
||||
def enabled_servers(self) -> list[HubServer]:
|
||||
"""Get all enabled servers.
|
||||
|
||||
:returns: List of enabled hub servers.
|
||||
|
||||
"""
|
||||
return [s for s in self._servers.values() if s.config.enabled]
|
||||
|
||||
def get_server(self, name: str) -> HubServer | None:
|
||||
"""Get a server by name.
|
||||
|
||||
:param name: Server name.
|
||||
:returns: HubServer if found, None otherwise.
|
||||
|
||||
"""
|
||||
return self._servers.get(name)
|
||||
|
||||
def add_server(self, config: HubServerConfig) -> HubServer:
|
||||
"""Add a server to the registry.
|
||||
|
||||
:param config: Server configuration.
|
||||
:returns: Created HubServer instance.
|
||||
:raises ValueError: If server with same name exists.
|
||||
|
||||
"""
|
||||
if config.name in self._servers:
|
||||
msg = f"Server '{config.name}' already exists"
|
||||
raise ValueError(msg)
|
||||
|
||||
server = HubServer(config=config)
|
||||
self._servers[config.name] = server
|
||||
self._config.servers.append(config)
|
||||
|
||||
get_logger().info("Added hub server", name=config.name, type=config.type)
|
||||
return server
|
||||
|
||||
def remove_server(self, name: str) -> bool:
|
||||
"""Remove a server from the registry.
|
||||
|
||||
:param name: Server name.
|
||||
:returns: True if removed, False if not found.
|
||||
|
||||
"""
|
||||
if name not in self._servers:
|
||||
return False
|
||||
|
||||
del self._servers[name]
|
||||
self._config.servers = [s for s in self._config.servers if s.name != name]
|
||||
|
||||
get_logger().info("Removed hub server", name=name)
|
||||
return True
|
||||
|
||||
def save_config(self, path: Path | None = None) -> None:
|
||||
"""Save current configuration to file.
|
||||
|
||||
:param path: Path to save to. Uses original path if None.
|
||||
|
||||
"""
|
||||
save_path = path or self._config_path
|
||||
if not save_path:
|
||||
msg = "No config path specified"
|
||||
raise ValueError(msg)
|
||||
|
||||
with save_path.open("w") as f:
|
||||
json.dump(
|
||||
self._config.model_dump(mode="json"),
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
get_logger().info("Saved hub configuration", path=str(save_path))
|
||||
|
||||
def update_server_tools(
|
||||
self,
|
||||
server_name: str,
|
||||
tools: list,
|
||||
*,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Update discovered tools for a server.
|
||||
|
||||
Called by the hub client after tool discovery.
|
||||
|
||||
:param server_name: Server name.
|
||||
:param tools: List of HubTool instances.
|
||||
:param error: Error message if discovery failed.
|
||||
|
||||
"""
|
||||
server = self._servers.get(server_name)
|
||||
if not server:
|
||||
return
|
||||
|
||||
if error:
|
||||
server.discovered = False
|
||||
server.discovery_error = error
|
||||
server.tools = []
|
||||
else:
|
||||
server.discovered = True
|
||||
server.discovery_error = None
|
||||
server.tools = tools
|
||||
|
||||
def get_workflow_hint(self, tool_name: str) -> dict | None:
|
||||
"""Get the workflow hint for a tool by name.
|
||||
|
||||
:param tool_name: Tool name (e.g. ``binwalk_extract``).
|
||||
:returns: Hint dict for the ``after:<tool_name>`` key, or None.
|
||||
|
||||
"""
|
||||
return self._config.workflow_hints.get(f"after:{tool_name}") or None
|
||||
|
||||
def get_all_tools(self) -> list:
|
||||
"""Get all discovered tools from all servers.
|
||||
|
||||
:returns: Flat list of all HubTool instances.
|
||||
|
||||
"""
|
||||
tools = []
|
||||
for server in self._servers.values():
|
||||
if server.discovered:
|
||||
tools.extend(server.tools)
|
||||
return tools
|
||||
|
||||
def find_tool(self, identifier: str):
|
||||
"""Find a tool by its full identifier.
|
||||
|
||||
:param identifier: Full identifier (hub:server:tool or server:tool).
|
||||
:returns: Tuple of (HubServer, HubTool) if found, (None, None) otherwise.
|
||||
|
||||
"""
|
||||
# Parse identifier
|
||||
parts = identifier.split(":")
|
||||
if len(parts) == 3 and parts[0] == "hub": # noqa: PLR2004
|
||||
# hub:server:tool format
|
||||
server_name = parts[1]
|
||||
tool_name = parts[2]
|
||||
elif len(parts) == 2: # noqa: PLR2004
|
||||
# server:tool format
|
||||
server_name = parts[0]
|
||||
tool_name = parts[1]
|
||||
else:
|
||||
return None, None
|
||||
|
||||
server = self._servers.get(server_name)
|
||||
if not server:
|
||||
return None, None
|
||||
|
||||
tool = server.get_tool(tool_name)
|
||||
return server, tool
|
||||
@@ -0,0 +1,23 @@
|
||||
"""SecPipe sandbox abstractions and implementations."""
|
||||
|
||||
from secpipe_common.sandboxes.engines import (
|
||||
AbstractSecPipeEngineConfiguration,
|
||||
AbstractSecPipeSandboxEngine,
|
||||
Docker,
|
||||
DockerConfiguration,
|
||||
SecPipeSandboxEngines,
|
||||
ImageInfo,
|
||||
Podman,
|
||||
PodmanConfiguration,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AbstractSecPipeEngineConfiguration",
|
||||
"AbstractSecPipeSandboxEngine",
|
||||
"Docker",
|
||||
"DockerConfiguration",
|
||||
"SecPipeSandboxEngines",
|
||||
"ImageInfo",
|
||||
"Podman",
|
||||
"PodmanConfiguration",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Container engine implementations for SecPipe sandboxes."""
|
||||
|
||||
from secpipe_common.sandboxes.engines.base import (
|
||||
AbstractSecPipeEngineConfiguration,
|
||||
AbstractSecPipeSandboxEngine,
|
||||
ImageInfo,
|
||||
)
|
||||
from secpipe_common.sandboxes.engines.docker import Docker, DockerConfiguration
|
||||
from secpipe_common.sandboxes.engines.enumeration import SecPipeSandboxEngines
|
||||
from secpipe_common.sandboxes.engines.podman import Podman, PodmanConfiguration
|
||||
|
||||
__all__ = [
|
||||
"AbstractSecPipeEngineConfiguration",
|
||||
"AbstractSecPipeSandboxEngine",
|
||||
"Docker",
|
||||
"DockerConfiguration",
|
||||
"SecPipeSandboxEngines",
|
||||
"ImageInfo",
|
||||
"Podman",
|
||||
"PodmanConfiguration",
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Base engine abstractions."""
|
||||
|
||||
from secpipe_common.sandboxes.engines.base.configuration import (
|
||||
AbstractSecPipeEngineConfiguration,
|
||||
)
|
||||
from secpipe_common.sandboxes.engines.base.engine import (
|
||||
AbstractSecPipeSandboxEngine,
|
||||
ImageInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AbstractSecPipeEngineConfiguration",
|
||||
"AbstractSecPipeSandboxEngine",
|
||||
"ImageInfo",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from secpipe_common.sandboxes.engines.enumeration import (
|
||||
SecPipeSandboxEngines,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine
|
||||
|
||||
|
||||
class AbstractSecPipeEngineConfiguration(ABC, BaseModel):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
kind: SecPipeSandboxEngines
|
||||
|
||||
@abstractmethod
|
||||
def into_engine(self) -> AbstractSecPipeSandboxEngine:
|
||||
"""TODO."""
|
||||
message: str = f"method 'into_engine' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
@@ -0,0 +1,315 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageInfo:
|
||||
"""Information about a container image."""
|
||||
|
||||
#: Full image reference (e.g., "localhost/secpipe-module-echidna:latest").
|
||||
reference: str
|
||||
|
||||
#: Repository name (e.g., "localhost/secpipe-module-echidna").
|
||||
repository: str
|
||||
|
||||
#: Image tag (e.g., "latest").
|
||||
tag: str
|
||||
|
||||
#: Image ID (short hash).
|
||||
image_id: str | None = None
|
||||
|
||||
#: Image size in bytes.
|
||||
size: int | None = None
|
||||
|
||||
#: Image labels/metadata.
|
||||
labels: dict[str, str] | None = None
|
||||
|
||||
|
||||
class AbstractSecPipeSandboxEngine(ABC):
|
||||
"""Abstract class used as a base for all SecPipe sandbox engine classes."""
|
||||
|
||||
@abstractmethod
|
||||
def list_images(self, filter_prefix: str | None = None) -> list[ImageInfo]:
|
||||
"""List available container images.
|
||||
|
||||
:param filter_prefix: Optional prefix to filter images (e.g., "localhost/").
|
||||
:returns: List of ImageInfo objects for available images.
|
||||
|
||||
"""
|
||||
message: str = f"method 'list_images' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def register_archive(self, archive: Path, repository: str) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param archive: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method 'register_archive' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def spawn_sandbox(self, image: str) -> str:
|
||||
"""Spawn a sandbox based on the given image.
|
||||
|
||||
:param image: The image the sandbox should be based on.
|
||||
:returns: The sandbox identifier.
|
||||
|
||||
"""
|
||||
message: str = f"method 'spawn_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def push_archive_to_sandbox(self, identifier: str, source: Path, destination: PurePath) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:param destination: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method 'push_archive_to_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def start_sandbox(self, identifier: str) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: The identifier of the sandbox to start.
|
||||
|
||||
"""
|
||||
message: str = f"method 'start_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def execute_inside_sandbox(self, identifier: str, command: list[str]) -> None:
|
||||
"""Execute a command inside the sandbox matching the given identifier and wait for completion.
|
||||
|
||||
:param sandbox: The identifier of the sandbox.
|
||||
:param command: The command to run.
|
||||
|
||||
"""
|
||||
message: str = f"method 'execute_inside_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def pull_archive_from_sandbox(self, identifier: str, source: PurePath) -> Path:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:returns: TODO.
|
||||
|
||||
"""
|
||||
message: str = f"method 'pull_archive_from_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def terminate_sandbox(self, identifier: str) -> None:
|
||||
"""Terminate the sandbox matching the given identifier.
|
||||
|
||||
:param identifier: The identifier of the sandbox to terminate.
|
||||
|
||||
"""
|
||||
message: str = f"method 'terminate_sandbox' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Extended Container Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def image_exists(self, image: str) -> bool:
|
||||
"""Check if a container image exists locally.
|
||||
|
||||
:param image: Full image reference (e.g., "localhost/module:latest").
|
||||
:returns: True if image exists, False otherwise.
|
||||
|
||||
"""
|
||||
message: str = f"method 'image_exists' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def pull_image(self, image: str, timeout: int = 300) -> None:
|
||||
"""Pull an image from a container registry.
|
||||
|
||||
:param image: Full image reference to pull.
|
||||
:param timeout: Timeout in seconds for the pull operation.
|
||||
:raises SecPipeError: If pull fails.
|
||||
|
||||
"""
|
||||
message: str = f"method 'pull_image' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def tag_image(self, source: str, target: str) -> None:
|
||||
"""Tag an image with a new name.
|
||||
|
||||
:param source: Source image reference.
|
||||
:param target: Target image reference.
|
||||
|
||||
"""
|
||||
message: str = f"method 'tag_image' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def create_container(
|
||||
self,
|
||||
image: str,
|
||||
volumes: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a container from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:param volumes: Optional volume mappings {host_path: container_path}.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
message: str = f"method 'create_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def start_container_attached(
|
||||
self,
|
||||
identifier: str,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Start a container and wait for it to complete.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Timeout in seconds for execution.
|
||||
:returns: Tuple of (exit_code, stdout, stderr).
|
||||
|
||||
"""
|
||||
message: str = f"method 'start_container_attached' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def copy_to_container(self, identifier: str, source: Path, destination: str) -> None:
|
||||
"""Copy a file or directory to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path on host.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
message: str = f"method 'copy_to_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def copy_from_container(self, identifier: str, source: str, destination: Path) -> None:
|
||||
"""Copy a file or directory from a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:param destination: Destination path on host.
|
||||
|
||||
"""
|
||||
message: str = f"method 'copy_from_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def remove_container(self, identifier: str, *, force: bool = False) -> None:
|
||||
"""Remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param force: Force removal even if running.
|
||||
|
||||
"""
|
||||
message: str = f"method 'remove_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Continuous/Background Execution Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def start_container(self, identifier: str) -> None:
|
||||
"""Start a container without waiting for it to complete (detached mode).
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
message: str = f"method 'start_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def get_container_status(self, identifier: str) -> str:
|
||||
"""Get the status of a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:returns: Container status (e.g., "running", "exited", "created").
|
||||
|
||||
"""
|
||||
message: str = f"method 'get_container_status' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def stop_container(self, identifier: str, timeout: int = 10) -> None:
|
||||
"""Stop a running container gracefully.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Seconds to wait before killing.
|
||||
|
||||
"""
|
||||
message: str = f"method 'stop_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def read_file_from_container(self, identifier: str, path: str) -> str:
|
||||
"""Read a file from inside a running container using exec.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
message: str = f"method 'read_file_from_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a running container starting at a given line number.
|
||||
|
||||
Uses ``tail -n +{start_line}`` to avoid re-reading the entire file on
|
||||
every poll. This is the preferred method for incremental reads of
|
||||
append-only files such as ``stream.jsonl``.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards (may be empty).
|
||||
|
||||
"""
|
||||
message: str = f"method 'tail_file_from_container' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
:param all_containers: Include stopped containers.
|
||||
:returns: List of container info dicts.
|
||||
|
||||
"""
|
||||
message: str = f"method 'list_containers' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
@abstractmethod
|
||||
def read_file_from_image(self, image: str, path: str) -> str:
|
||||
"""Read a file from inside an image without starting a container.
|
||||
|
||||
Creates a temporary container, copies the file, and removes the container.
|
||||
|
||||
:param image: Image reference (e.g., "secpipe-rust-analyzer:latest").
|
||||
:param path: Path to file inside image.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
message: str = f"method 'read_file_from_image' is not implemented for class '{self.__class__.__name__}'"
|
||||
raise NotImplementedError(message)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Docker container engine implementation."""
|
||||
|
||||
from secpipe_common.sandboxes.engines.docker.cli import DockerCLI
|
||||
from secpipe_common.sandboxes.engines.docker.configuration import (
|
||||
DockerConfiguration,
|
||||
)
|
||||
from secpipe_common.sandboxes.engines.docker.engine import Docker
|
||||
|
||||
__all__ = [
|
||||
"Docker",
|
||||
"DockerCLI",
|
||||
"DockerConfiguration",
|
||||
]
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Docker CLI engine.
|
||||
|
||||
This engine uses subprocess calls to the Docker CLI, providing a simple
|
||||
and portable interface that works on Linux, macOS, and Windows wherever
|
||||
Docker Desktop or Docker Engine is installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path, PurePath
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from secpipe_common.exceptions import SecPipeError
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine, ImageInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structured logger."""
|
||||
from structlog import get_logger # noqa: PLC0415 (required by temporal)
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class DockerCLI(AbstractSecPipeSandboxEngine):
|
||||
"""Docker engine using CLI commands.
|
||||
|
||||
This implementation uses subprocess calls to the Docker CLI,
|
||||
providing a simple and portable interface that works wherever
|
||||
Docker is installed (Docker Desktop on macOS/Windows, Docker Engine on Linux).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the DockerCLI engine."""
|
||||
AbstractSecPipeSandboxEngine.__init__(self)
|
||||
|
||||
def _base_cmd(self) -> list[str]:
|
||||
"""Get base Docker command.
|
||||
|
||||
:returns: Base command list.
|
||||
|
||||
"""
|
||||
return ["docker"]
|
||||
|
||||
def _run(self, args: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a Docker command.
|
||||
|
||||
:param args: Command arguments (without 'docker').
|
||||
:param check: Raise exception on non-zero exit.
|
||||
:param capture: Capture stdout/stderr.
|
||||
:returns: CompletedProcess result.
|
||||
|
||||
"""
|
||||
cmd = self._base_cmd() + args
|
||||
get_logger().debug("running docker command", cmd=" ".join(cmd))
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
check=check,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Image Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def list_images(self, filter_prefix: str | None = None) -> list[ImageInfo]:
|
||||
"""List available container images.
|
||||
|
||||
:param filter_prefix: Optional prefix to filter images.
|
||||
:returns: List of ImageInfo objects.
|
||||
|
||||
"""
|
||||
result = self._run(["images", "--format", "json"])
|
||||
images: list[ImageInfo] = []
|
||||
|
||||
try:
|
||||
# Docker outputs one JSON object per line
|
||||
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
data = [json.loads(line) for line in lines if line.strip()]
|
||||
except json.JSONDecodeError:
|
||||
get_logger().warning("failed to parse docker images output")
|
||||
return images
|
||||
|
||||
for image in data:
|
||||
repo = image.get("Repository", "")
|
||||
tag = image.get("Tag", "latest")
|
||||
|
||||
if repo == "<none>":
|
||||
continue
|
||||
|
||||
reference = f"{repo}:{tag}"
|
||||
|
||||
if filter_prefix and filter_prefix not in reference:
|
||||
continue
|
||||
|
||||
# Try to get labels from image inspect
|
||||
labels = {}
|
||||
try:
|
||||
inspect_result = self._run(["image", "inspect", reference], check=False)
|
||||
if inspect_result.returncode == 0:
|
||||
inspect_data = json.loads(inspect_result.stdout)
|
||||
if inspect_data and len(inspect_data) > 0:
|
||||
labels = inspect_data[0].get("Config", {}).get("Labels") or {}
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
pass
|
||||
|
||||
images.append(
|
||||
ImageInfo(
|
||||
reference=reference,
|
||||
repository=repo,
|
||||
tag=tag,
|
||||
image_id=image.get("ID", "")[:12],
|
||||
size=image.get("Size"),
|
||||
labels=labels,
|
||||
)
|
||||
)
|
||||
|
||||
get_logger().debug("listed images", count=len(images), filter_prefix=filter_prefix)
|
||||
return images
|
||||
|
||||
def image_exists(self, image: str) -> bool:
|
||||
"""Check if a container image exists locally.
|
||||
|
||||
:param image: Full image reference.
|
||||
:returns: True if image exists.
|
||||
|
||||
"""
|
||||
result = self._run(["image", "inspect", image], check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def pull_image(self, image: str, timeout: int = 300) -> None:
|
||||
"""Pull an image from a container registry.
|
||||
|
||||
:param image: Full image reference.
|
||||
:param timeout: Timeout in seconds.
|
||||
|
||||
"""
|
||||
get_logger().info("pulling image", image=image)
|
||||
try:
|
||||
self._run(["pull", image])
|
||||
get_logger().info("image pulled successfully", image=image)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
message = f"Failed to pull image '{image}': {exc.stderr}"
|
||||
raise SecPipeError(message) from exc
|
||||
|
||||
def tag_image(self, source: str, target: str) -> None:
|
||||
"""Tag an image with a new name.
|
||||
|
||||
:param source: Source image reference.
|
||||
:param target: Target image reference.
|
||||
|
||||
"""
|
||||
self._run(["tag", source, target])
|
||||
get_logger().debug("tagged image", source=source, target=target)
|
||||
|
||||
def build_image(self, context_path: Path, tag: str, dockerfile: str = "Dockerfile") -> None:
|
||||
"""Build an image from a Dockerfile.
|
||||
|
||||
:param context_path: Path to build context.
|
||||
:param tag: Image tag.
|
||||
:param dockerfile: Dockerfile name.
|
||||
|
||||
"""
|
||||
get_logger().info("building image", tag=tag, context=str(context_path))
|
||||
self._run(["build", "-t", tag, "-f", dockerfile, str(context_path)])
|
||||
get_logger().info("image built successfully", tag=tag)
|
||||
|
||||
def register_archive(self, archive: Path, repository: str) -> None:
|
||||
"""Load an image from a tar archive.
|
||||
|
||||
:param archive: Path to tar archive.
|
||||
:param repository: Repository name for the loaded image.
|
||||
|
||||
"""
|
||||
result = self._run(["load", "-i", str(archive)])
|
||||
# Tag the loaded image
|
||||
for line in result.stdout.splitlines():
|
||||
if "Loaded image:" in line:
|
||||
loaded_image = line.split("Loaded image:")[-1].strip()
|
||||
self._run(["tag", loaded_image, f"{repository}:latest"])
|
||||
break
|
||||
get_logger().debug("registered archive", archive=str(archive), repository=repository)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Container Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def spawn_sandbox(self, image: str) -> str:
|
||||
"""Spawn a sandbox (container) from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
result = self._run(["create", image])
|
||||
container_id = result.stdout.strip()
|
||||
get_logger().debug("created container", container_id=container_id)
|
||||
return container_id
|
||||
|
||||
def create_container(
|
||||
self,
|
||||
image: str,
|
||||
volumes: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a container from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:param volumes: Optional volume mappings {host_path: container_path}.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
args = ["create"]
|
||||
if volumes:
|
||||
for host_path, container_path in volumes.items():
|
||||
args.extend(["-v", f"{host_path}:{container_path}:ro"])
|
||||
args.append(image)
|
||||
|
||||
result = self._run(args)
|
||||
container_id = result.stdout.strip()
|
||||
get_logger().debug("created container", container_id=container_id, image=image)
|
||||
return container_id
|
||||
|
||||
def start_sandbox(self, identifier: str) -> None:
|
||||
"""Start a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
self._run(["start", identifier])
|
||||
get_logger().debug("started container", container_id=identifier)
|
||||
|
||||
def start_container(self, identifier: str) -> None:
|
||||
"""Start a container without waiting.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
self._run(["start", identifier])
|
||||
get_logger().debug("started container (detached)", container_id=identifier)
|
||||
|
||||
def start_container_attached(
|
||||
self,
|
||||
identifier: str,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Start a container and wait for completion.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Timeout in seconds.
|
||||
:returns: Tuple of (exit_code, stdout, stderr).
|
||||
|
||||
"""
|
||||
get_logger().debug("starting container attached", container_id=identifier)
|
||||
# Start the container
|
||||
self._run(["start", identifier])
|
||||
|
||||
# Wait for completion
|
||||
wait_result = self._run(["wait", identifier])
|
||||
exit_code = int(wait_result.stdout.strip()) if wait_result.stdout.strip() else -1
|
||||
|
||||
# Get logs
|
||||
stdout_result = self._run(["logs", identifier], check=False)
|
||||
stdout_str = stdout_result.stdout or ""
|
||||
stderr_str = stdout_result.stderr or ""
|
||||
|
||||
get_logger().debug("container finished", container_id=identifier, exit_code=exit_code)
|
||||
return (exit_code, stdout_str, stderr_str)
|
||||
|
||||
def execute_inside_sandbox(self, identifier: str, command: list[str]) -> None:
|
||||
"""Execute a command inside a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param command: Command to run.
|
||||
|
||||
"""
|
||||
get_logger().debug("executing command in container", container_id=identifier)
|
||||
self._run(["exec", identifier] + command)
|
||||
|
||||
def push_archive_to_sandbox(self, identifier: str, source: Path, destination: PurePath) -> None:
|
||||
"""Copy an archive to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source archive path.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
get_logger().debug("copying to container", container_id=identifier, source=str(source))
|
||||
self._run(["cp", str(source), f"{identifier}:{destination}"])
|
||||
|
||||
def pull_archive_from_sandbox(self, identifier: str, source: PurePath) -> Path:
|
||||
"""Copy files from a container to a local archive.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:returns: Path to local archive.
|
||||
|
||||
"""
|
||||
get_logger().debug("copying from container", container_id=identifier, source=str(source))
|
||||
with NamedTemporaryFile(delete=False, delete_on_close=False, suffix=".tar") as tmp:
|
||||
self._run(["cp", f"{identifier}:{source}", tmp.name])
|
||||
return Path(tmp.name)
|
||||
|
||||
def copy_to_container(self, identifier: str, source: Path, destination: str) -> None:
|
||||
"""Copy a file or directory to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path on host.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
self._run(["cp", str(source), f"{identifier}:{destination}"])
|
||||
get_logger().debug("copied to container", source=str(source), destination=destination)
|
||||
|
||||
def copy_from_container(self, identifier: str, source: str, destination: Path) -> None:
|
||||
"""Copy a file or directory from a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:param destination: Destination path on host.
|
||||
|
||||
"""
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
self._run(["cp", f"{identifier}:{source}", str(destination)])
|
||||
get_logger().debug("copied from container", source=source, destination=str(destination))
|
||||
|
||||
def terminate_sandbox(self, identifier: str) -> None:
|
||||
"""Terminate and remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
# Stop if running
|
||||
self._run(["stop", identifier], check=False)
|
||||
# Remove
|
||||
self._run(["rm", "-f", identifier], check=False)
|
||||
get_logger().debug("terminated container", container_id=identifier)
|
||||
|
||||
def remove_container(self, identifier: str, *, force: bool = False) -> None:
|
||||
"""Remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param force: Force removal.
|
||||
|
||||
"""
|
||||
args = ["rm"]
|
||||
if force:
|
||||
args.append("-f")
|
||||
args.append(identifier)
|
||||
self._run(args, check=False)
|
||||
get_logger().debug("removed container", container_id=identifier)
|
||||
|
||||
def stop_container(self, identifier: str, timeout: int = 10) -> None:
|
||||
"""Stop a running container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Seconds to wait before killing.
|
||||
|
||||
"""
|
||||
self._run(["stop", "-t", str(timeout), identifier], check=False)
|
||||
get_logger().debug("stopped container", container_id=identifier)
|
||||
|
||||
def get_container_status(self, identifier: str) -> str:
|
||||
"""Get the status of a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:returns: Container status.
|
||||
|
||||
"""
|
||||
result = self._run(["inspect", "--format", "{{.State.Status}}", identifier], check=False)
|
||||
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
||||
|
||||
def read_file_from_container(self, identifier: str, path: str) -> str:
|
||||
"""Read a file from inside a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:returns: File contents.
|
||||
|
||||
"""
|
||||
result = self._run(["exec", identifier, "cat", path], check=False)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to read file from container", path=path)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
result = self._run(
|
||||
["exec", identifier, "tail", "-n", f"+{start_line}", path],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to tail file from container", path=path, start_line=start_line)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
:param all_containers: Include stopped containers.
|
||||
:returns: List of container info dicts.
|
||||
|
||||
"""
|
||||
args = ["ps", "--format", "json"]
|
||||
if all_containers:
|
||||
args.append("-a")
|
||||
|
||||
result = self._run(args)
|
||||
try:
|
||||
# Docker outputs one JSON object per line
|
||||
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
data = [json.loads(line) for line in lines if line.strip()]
|
||||
return [
|
||||
{
|
||||
"Id": c.get("ID", ""),
|
||||
"Names": c.get("Names", ""),
|
||||
"Status": c.get("State", ""),
|
||||
"Image": c.get("Image", ""),
|
||||
}
|
||||
for c in data
|
||||
]
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def read_file_from_image(self, image: str, path: str) -> str:
|
||||
"""Read a file from inside an image without starting a long-running container.
|
||||
|
||||
Uses docker run with --entrypoint override to read the file via cat.
|
||||
|
||||
:param image: Image reference (e.g., "secpipe-rust-analyzer:latest").
|
||||
:param path: Path to file inside image.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
# Use docker run with --entrypoint to override any container entrypoint
|
||||
result = self._run(
|
||||
["run", "--rm", "--entrypoint", "cat", image, path],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.debug("failed to read file from image", image=image, path=path, stderr=result.stderr)
|
||||
return ""
|
||||
|
||||
return result.stdout
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from secpipe_common.sandboxes.engines.base.configuration import AbstractSecPipeEngineConfiguration
|
||||
from secpipe_common.sandboxes.engines.docker.engine import Docker
|
||||
from secpipe_common.sandboxes.engines.enumeration import SecPipeSandboxEngines
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine
|
||||
|
||||
|
||||
class DockerConfiguration(AbstractSecPipeEngineConfiguration):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
kind: Literal[SecPipeSandboxEngines.DOCKER] = SecPipeSandboxEngines.DOCKER
|
||||
|
||||
#: TODO.
|
||||
socket: str
|
||||
|
||||
def into_engine(self) -> AbstractSecPipeSandboxEngine:
|
||||
"""TODO."""
|
||||
return Docker(socket=self.socket)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine, ImageInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
|
||||
class Docker(AbstractSecPipeSandboxEngine):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
__socket: str
|
||||
|
||||
def __init__(self, socket: str) -> None:
|
||||
"""Initialize an instance of the class.
|
||||
|
||||
:param socket: TODO.
|
||||
|
||||
"""
|
||||
super().__init__()
|
||||
self.__socket = socket
|
||||
|
||||
def list_images(self, filter_prefix: str | None = None) -> list[ImageInfo]:
|
||||
"""List available container images.
|
||||
|
||||
:param filter_prefix: Optional prefix to filter images (e.g., "localhost/").
|
||||
:returns: List of ImageInfo objects for available images.
|
||||
|
||||
"""
|
||||
# TODO: Implement Docker image listing
|
||||
message: str = "Docker engine list_images is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def register_archive(self, archive: Path, repository: str) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param archive: TODO.
|
||||
|
||||
"""
|
||||
return super().register_archive(archive=archive, repository=repository)
|
||||
|
||||
def spawn_sandbox(self, image: str) -> str:
|
||||
"""Spawn a sandbox based on the given image.
|
||||
|
||||
:param image: The image the sandbox should be based on.
|
||||
:returns: The sandbox identifier.
|
||||
|
||||
"""
|
||||
return super().spawn_sandbox(image)
|
||||
|
||||
def push_archive_to_sandbox(self, identifier: str, source: Path, destination: PurePath) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:param destination: TODO.
|
||||
|
||||
"""
|
||||
super().push_archive_to_sandbox(identifier, source, destination)
|
||||
|
||||
def start_sandbox(self, identifier: str) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: The identifier of the sandbox to start.
|
||||
|
||||
"""
|
||||
super().start_sandbox(identifier)
|
||||
|
||||
def execute_inside_sandbox(self, identifier: str, command: list[str]) -> None:
|
||||
"""Execute a command inside the sandbox matching the given identifier and wait for completion.
|
||||
|
||||
:param sandbox: The identifier of the sandbox.
|
||||
:param command: The command to run.
|
||||
|
||||
"""
|
||||
super().execute_inside_sandbox(identifier, command)
|
||||
|
||||
def pull_archive_from_sandbox(self, identifier: str, source: PurePath) -> Path:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:returns: TODO.
|
||||
|
||||
"""
|
||||
return super().pull_archive_from_sandbox(identifier, source)
|
||||
|
||||
def terminate_sandbox(self, identifier: str) -> None:
|
||||
"""Terminate the sandbox matching the given identifier.
|
||||
|
||||
:param identifier: The identifier of the sandbox to terminate.
|
||||
|
||||
"""
|
||||
super().terminate_sandbox(identifier)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Extended Container Operations (stubs - not yet implemented)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def image_exists(self, image: str) -> bool:
|
||||
"""Check if a container image exists locally."""
|
||||
message: str = "Docker engine image_exists is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def pull_image(self, image: str, timeout: int = 300) -> None:
|
||||
"""Pull an image from a container registry."""
|
||||
message: str = "Docker engine pull_image is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def tag_image(self, source: str, target: str) -> None:
|
||||
"""Tag an image with a new name."""
|
||||
message: str = "Docker engine tag_image is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def create_container(
|
||||
self,
|
||||
image: str,
|
||||
volumes: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a container from an image."""
|
||||
message: str = "Docker engine create_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def start_container_attached(
|
||||
self,
|
||||
identifier: str,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Start a container and wait for it to complete."""
|
||||
message: str = "Docker engine start_container_attached is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def copy_to_container(self, identifier: str, source: Path, destination: str) -> None:
|
||||
"""Copy a file or directory to a container."""
|
||||
message: str = "Docker engine copy_to_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def copy_from_container(self, identifier: str, source: str, destination: Path) -> None:
|
||||
"""Copy a file or directory from a container."""
|
||||
message: str = "Docker engine copy_from_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def remove_container(self, identifier: str, *, force: bool = False) -> None:
|
||||
"""Remove a container."""
|
||||
message: str = "Docker engine remove_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def start_container(self, identifier: str) -> None:
|
||||
"""Start a container without waiting for it to complete."""
|
||||
message: str = "Docker engine start_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def get_container_status(self, identifier: str) -> str:
|
||||
"""Get the status of a container."""
|
||||
message: str = "Docker engine get_container_status is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def stop_container(self, identifier: str, timeout: int = 10) -> None:
|
||||
"""Stop a running container gracefully."""
|
||||
message: str = "Docker engine stop_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def read_file_from_container(self, identifier: str, path: str) -> str:
|
||||
"""Read a file from inside a running container using exec."""
|
||||
message: str = "Docker engine read_file_from_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number."""
|
||||
message: str = "Docker engine tail_file_from_container is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers."""
|
||||
message: str = "Docker engine list_containers is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
|
||||
def read_file_from_image(self, image: str, path: str) -> str:
|
||||
"""Read a file from inside an image without starting a long-running container."""
|
||||
message: str = "Docker engine read_file_from_image is not yet implemented"
|
||||
raise NotImplementedError(message)
|
||||
@@ -0,0 +1,11 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class SecPipeSandboxEngines(StrEnum):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
DOCKER = "docker"
|
||||
|
||||
#: TODO.
|
||||
PODMAN = "podman"
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Podman container engine implementation."""
|
||||
|
||||
from secpipe_common.sandboxes.engines.podman.cli import PodmanCLI
|
||||
from secpipe_common.sandboxes.engines.podman.configuration import (
|
||||
PodmanConfiguration,
|
||||
)
|
||||
from secpipe_common.sandboxes.engines.podman.engine import Podman
|
||||
|
||||
__all__ = [
|
||||
"Podman",
|
||||
"PodmanCLI",
|
||||
"PodmanConfiguration",
|
||||
]
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Podman CLI engine with custom storage support.
|
||||
|
||||
This engine uses subprocess calls to the Podman CLI instead of the socket API,
|
||||
allowing for custom storage paths (--root, --runroot) that work regardless of
|
||||
system Podman configuration or snap environment issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path, PurePath
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from secpipe_common.exceptions import SecPipeError
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine, ImageInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""Get structured logger."""
|
||||
from structlog import get_logger # noqa: PLC0415 (required by temporal)
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
def _is_running_under_snap() -> bool:
|
||||
"""Check if running under Snap environment.
|
||||
|
||||
VS Code installed via Snap sets XDG_DATA_HOME to a version-specific path,
|
||||
causing Podman to look for storage in non-standard locations. When SNAP
|
||||
is set, we use custom storage paths to ensure consistency.
|
||||
|
||||
Note: Snap only exists on Linux, so this also handles macOS implicitly.
|
||||
"""
|
||||
import os # noqa: PLC0415
|
||||
|
||||
return os.getenv("SNAP") is not None
|
||||
|
||||
|
||||
class PodmanCLI(AbstractSecPipeSandboxEngine):
|
||||
"""Podman engine using CLI with custom storage paths.
|
||||
|
||||
This implementation uses subprocess calls to the Podman CLI with --root
|
||||
and --runroot flags when running under Snap, providing isolation from
|
||||
system Podman storage.
|
||||
|
||||
The custom storage is only used when:
|
||||
1. Running under Snap (SNAP env var is set) - to fix XDG_DATA_HOME issues
|
||||
2. Custom paths are explicitly provided
|
||||
|
||||
Otherwise, uses default Podman storage which works for:
|
||||
- Native Linux installations
|
||||
- macOS (where Podman runs in a VM via podman machine)
|
||||
"""
|
||||
|
||||
__graphroot: Path | None
|
||||
__runroot: Path | None
|
||||
__use_custom_storage: bool
|
||||
|
||||
def __init__(self, graphroot: Path | None = None, runroot: Path | None = None) -> None:
|
||||
"""Initialize the PodmanCLI engine.
|
||||
|
||||
:param graphroot: Path to container image storage.
|
||||
:param runroot: Path to container runtime state.
|
||||
|
||||
Custom storage is used when running under Snap AND paths are provided.
|
||||
|
||||
:raises SecPipeError: If running on macOS (Podman not supported).
|
||||
"""
|
||||
import sys # noqa: PLC0415
|
||||
|
||||
if sys.platform == "darwin":
|
||||
msg = (
|
||||
"Podman is not supported on macOS. Please use Docker instead:\n"
|
||||
" brew install --cask docker\n"
|
||||
" # Or download from https://docker.com/products/docker-desktop"
|
||||
)
|
||||
raise SecPipeError(msg)
|
||||
|
||||
AbstractSecPipeSandboxEngine.__init__(self)
|
||||
|
||||
# Use custom storage only under Snap (to fix XDG_DATA_HOME issues)
|
||||
self.__use_custom_storage = _is_running_under_snap() and graphroot is not None and runroot is not None
|
||||
|
||||
if self.__use_custom_storage:
|
||||
self.__graphroot = graphroot
|
||||
self.__runroot = runroot
|
||||
# Ensure directories exist
|
||||
self.__graphroot.mkdir(parents=True, exist_ok=True)
|
||||
self.__runroot.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
self.__graphroot = None
|
||||
self.__runroot = None
|
||||
|
||||
def _base_cmd(self) -> list[str]:
|
||||
"""Get base Podman command with storage flags.
|
||||
|
||||
:returns: Base command list, with --root and --runroot only under Snap.
|
||||
|
||||
"""
|
||||
if self.__use_custom_storage and self.__graphroot and self.__runroot:
|
||||
return [
|
||||
"podman",
|
||||
"--root",
|
||||
str(self.__graphroot),
|
||||
"--runroot",
|
||||
str(self.__runroot),
|
||||
]
|
||||
return ["podman"]
|
||||
|
||||
def _run(self, args: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a Podman command.
|
||||
|
||||
:param args: Command arguments (without 'podman').
|
||||
:param check: Raise exception on non-zero exit.
|
||||
:param capture: Capture stdout/stderr.
|
||||
:returns: CompletedProcess result.
|
||||
|
||||
"""
|
||||
cmd = self._base_cmd() + args
|
||||
get_logger().debug("running podman command", cmd=" ".join(cmd))
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
check=check,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Image Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def list_images(self, filter_prefix: str | None = None) -> list[ImageInfo]:
|
||||
"""List available container images.
|
||||
|
||||
:param filter_prefix: Optional prefix to filter images.
|
||||
:returns: List of ImageInfo objects.
|
||||
|
||||
"""
|
||||
result = self._run(["images", "--format", "json"])
|
||||
images: list[ImageInfo] = []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
except json.JSONDecodeError:
|
||||
get_logger().warning("failed to parse podman images output")
|
||||
return images
|
||||
|
||||
for image in data:
|
||||
# Get repository and tag from Names
|
||||
names = image.get("Names") or []
|
||||
for name in names:
|
||||
if filter_prefix and not name.startswith(filter_prefix):
|
||||
continue
|
||||
|
||||
# Parse repository and tag
|
||||
if ":" in name:
|
||||
repo, tag = name.rsplit(":", 1)
|
||||
else:
|
||||
repo = name
|
||||
tag = "latest"
|
||||
|
||||
# Get labels if available
|
||||
labels = image.get("Labels") or {}
|
||||
|
||||
images.append(
|
||||
ImageInfo(
|
||||
reference=name,
|
||||
repository=repo,
|
||||
tag=tag,
|
||||
image_id=image.get("Id", "")[:12],
|
||||
size=image.get("Size"),
|
||||
labels=labels,
|
||||
)
|
||||
)
|
||||
|
||||
get_logger().debug("listed images", count=len(images), filter_prefix=filter_prefix)
|
||||
return images
|
||||
|
||||
def image_exists(self, image: str) -> bool:
|
||||
"""Check if a container image exists locally.
|
||||
|
||||
:param image: Full image reference.
|
||||
:returns: True if image exists.
|
||||
|
||||
"""
|
||||
result = self._run(["image", "exists", image], check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def pull_image(self, image: str, timeout: int = 300) -> None:
|
||||
"""Pull an image from a container registry.
|
||||
|
||||
:param image: Full image reference.
|
||||
:param timeout: Timeout in seconds.
|
||||
|
||||
"""
|
||||
get_logger().info("pulling image", image=image)
|
||||
try:
|
||||
self._run(["pull", image])
|
||||
get_logger().info("image pulled successfully", image=image)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
message = f"Failed to pull image '{image}': {exc.stderr}"
|
||||
raise SecPipeError(message) from exc
|
||||
|
||||
def tag_image(self, source: str, target: str) -> None:
|
||||
"""Tag an image with a new name.
|
||||
|
||||
:param source: Source image reference.
|
||||
:param target: Target image reference.
|
||||
|
||||
"""
|
||||
self._run(["tag", source, target])
|
||||
get_logger().debug("tagged image", source=source, target=target)
|
||||
|
||||
def build_image(self, context_path: Path, tag: str, dockerfile: str = "Dockerfile") -> None:
|
||||
"""Build an image from a Dockerfile.
|
||||
|
||||
:param context_path: Path to build context.
|
||||
:param tag: Image tag.
|
||||
:param dockerfile: Dockerfile name.
|
||||
|
||||
"""
|
||||
get_logger().info("building image", tag=tag, context=str(context_path))
|
||||
self._run(["build", "-t", tag, "-f", dockerfile, str(context_path)])
|
||||
get_logger().info("image built successfully", tag=tag)
|
||||
|
||||
def register_archive(self, archive: Path, repository: str) -> None:
|
||||
"""Load an image from a tar archive.
|
||||
|
||||
:param archive: Path to tar archive.
|
||||
:param repository: Repository name for the loaded image.
|
||||
|
||||
"""
|
||||
result = self._run(["load", "-i", str(archive)])
|
||||
# Tag the loaded image
|
||||
# Parse loaded image ID from output
|
||||
for line in result.stdout.splitlines():
|
||||
if "Loaded image:" in line:
|
||||
loaded_image = line.split("Loaded image:")[-1].strip()
|
||||
self._run(["tag", loaded_image, f"{repository}:latest"])
|
||||
break
|
||||
get_logger().debug("registered archive", archive=str(archive), repository=repository)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Container Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def spawn_sandbox(self, image: str) -> str:
|
||||
"""Spawn a sandbox (container) from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
result = self._run(["create", image])
|
||||
container_id = result.stdout.strip()
|
||||
get_logger().debug("created container", container_id=container_id)
|
||||
return container_id
|
||||
|
||||
def create_container(
|
||||
self,
|
||||
image: str,
|
||||
volumes: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a container from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:param volumes: Optional volume mappings {host_path: container_path}.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
args = ["create"]
|
||||
if volumes:
|
||||
for host_path, container_path in volumes.items():
|
||||
args.extend(["-v", f"{host_path}:{container_path}:ro"])
|
||||
args.append(image)
|
||||
|
||||
result = self._run(args)
|
||||
container_id = result.stdout.strip()
|
||||
get_logger().debug("created container", container_id=container_id, image=image)
|
||||
return container_id
|
||||
|
||||
def start_sandbox(self, identifier: str) -> None:
|
||||
"""Start a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
self._run(["start", identifier])
|
||||
get_logger().debug("started container", container_id=identifier)
|
||||
|
||||
def start_container(self, identifier: str) -> None:
|
||||
"""Start a container without waiting.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
self._run(["start", identifier])
|
||||
get_logger().debug("started container (detached)", container_id=identifier)
|
||||
|
||||
def start_container_attached(
|
||||
self,
|
||||
identifier: str,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Start a container and wait for completion.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Timeout in seconds.
|
||||
:returns: Tuple of (exit_code, stdout, stderr).
|
||||
|
||||
"""
|
||||
get_logger().debug("starting container attached", container_id=identifier)
|
||||
# Start the container
|
||||
self._run(["start", identifier])
|
||||
|
||||
# Wait for completion
|
||||
wait_result = self._run(["wait", identifier])
|
||||
exit_code = int(wait_result.stdout.strip()) if wait_result.stdout.strip() else -1
|
||||
|
||||
# Get logs
|
||||
stdout_result = self._run(["logs", identifier], check=False)
|
||||
stdout_str = stdout_result.stdout or ""
|
||||
stderr_str = stdout_result.stderr or ""
|
||||
|
||||
get_logger().debug("container finished", container_id=identifier, exit_code=exit_code)
|
||||
return (exit_code, stdout_str, stderr_str)
|
||||
|
||||
def execute_inside_sandbox(self, identifier: str, command: list[str]) -> None:
|
||||
"""Execute a command inside a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param command: Command to run.
|
||||
|
||||
"""
|
||||
get_logger().debug("executing command in container", container_id=identifier)
|
||||
self._run(["exec", identifier] + command)
|
||||
|
||||
def push_archive_to_sandbox(self, identifier: str, source: Path, destination: PurePath) -> None:
|
||||
"""Copy an archive to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source archive path.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
get_logger().debug("copying to container", container_id=identifier, source=str(source))
|
||||
self._run(["cp", str(source), f"{identifier}:{destination}"])
|
||||
|
||||
def pull_archive_from_sandbox(self, identifier: str, source: PurePath) -> Path:
|
||||
"""Copy files from a container to a local archive.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:returns: Path to local archive.
|
||||
|
||||
"""
|
||||
get_logger().debug("copying from container", container_id=identifier, source=str(source))
|
||||
with NamedTemporaryFile(delete=False, delete_on_close=False, suffix=".tar") as tmp:
|
||||
self._run(["cp", f"{identifier}:{source}", tmp.name])
|
||||
return Path(tmp.name)
|
||||
|
||||
def copy_to_container(self, identifier: str, source: Path, destination: str) -> None:
|
||||
"""Copy a file or directory to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path on host.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
self._run(["cp", str(source), f"{identifier}:{destination}"])
|
||||
get_logger().debug("copied to container", source=str(source), destination=destination)
|
||||
|
||||
def copy_from_container(self, identifier: str, source: str, destination: Path) -> None:
|
||||
"""Copy a file or directory from a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:param destination: Destination path on host.
|
||||
|
||||
"""
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
self._run(["cp", f"{identifier}:{source}", str(destination)])
|
||||
get_logger().debug("copied from container", source=source, destination=str(destination))
|
||||
|
||||
def terminate_sandbox(self, identifier: str) -> None:
|
||||
"""Terminate and remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
# Stop if running
|
||||
self._run(["stop", identifier], check=False)
|
||||
# Remove
|
||||
self._run(["rm", "-f", identifier], check=False)
|
||||
get_logger().debug("terminated container", container_id=identifier)
|
||||
|
||||
def remove_container(self, identifier: str, *, force: bool = False) -> None:
|
||||
"""Remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param force: Force removal.
|
||||
|
||||
"""
|
||||
args = ["rm"]
|
||||
if force:
|
||||
args.append("-f")
|
||||
args.append(identifier)
|
||||
self._run(args, check=False)
|
||||
get_logger().debug("removed container", container_id=identifier)
|
||||
|
||||
def stop_container(self, identifier: str, timeout: int = 10) -> None:
|
||||
"""Stop a running container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Seconds to wait before killing.
|
||||
|
||||
"""
|
||||
self._run(["stop", "-t", str(timeout), identifier], check=False)
|
||||
get_logger().debug("stopped container", container_id=identifier)
|
||||
|
||||
def get_container_status(self, identifier: str) -> str:
|
||||
"""Get the status of a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:returns: Container status.
|
||||
|
||||
"""
|
||||
result = self._run(["inspect", "--format", "{{.State.Status}}", identifier], check=False)
|
||||
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
||||
|
||||
def read_file_from_container(self, identifier: str, path: str) -> str:
|
||||
"""Read a file from inside a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:returns: File contents.
|
||||
|
||||
"""
|
||||
result = self._run(["exec", identifier, "cat", path], check=False)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to read file from container", path=path)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file in container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
result = self._run(
|
||||
["exec", identifier, "tail", "-n", f"+{start_line}", path],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
get_logger().debug("failed to tail file from container", path=path, start_line=start_line)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
:param all_containers: Include stopped containers.
|
||||
:returns: List of container info dicts.
|
||||
|
||||
"""
|
||||
args = ["ps", "--format", "json"]
|
||||
if all_containers:
|
||||
args.append("-a")
|
||||
|
||||
result = self._run(args)
|
||||
try:
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
# Handle both list and single object responses
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
return [
|
||||
{
|
||||
"Id": c.get("Id", ""),
|
||||
"Names": c.get("Names", []),
|
||||
"Status": c.get("State", ""),
|
||||
"Image": c.get("Image", ""),
|
||||
}
|
||||
for c in data
|
||||
]
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def read_file_from_image(self, image: str, path: str) -> str:
|
||||
"""Read a file from inside an image without starting a long-running container.
|
||||
|
||||
Uses podman run with --entrypoint override to read the file via cat.
|
||||
|
||||
:param image: Image reference (e.g., "secpipe-rust-analyzer:latest").
|
||||
:param path: Path to file inside image.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
|
||||
# Use podman run with --entrypoint to override any container entrypoint
|
||||
result = self._run(
|
||||
["run", "--rm", "--entrypoint", "cat", image, path],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.debug("failed to read file from image", image=image, path=path, stderr=result.stderr)
|
||||
return ""
|
||||
|
||||
return result.stdout
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Utility Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_storage_info(self) -> dict:
|
||||
"""Get storage configuration info.
|
||||
|
||||
:returns: Dict with graphroot and runroot paths.
|
||||
|
||||
"""
|
||||
return {
|
||||
"graphroot": str(self.__graphroot),
|
||||
"runroot": str(self.__runroot),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from secpipe_common.sandboxes.engines.base.configuration import AbstractSecPipeEngineConfiguration
|
||||
from secpipe_common.sandboxes.engines.enumeration import SecPipeSandboxEngines
|
||||
from secpipe_common.sandboxes.engines.podman.engine import Podman
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine
|
||||
|
||||
|
||||
class PodmanConfiguration(AbstractSecPipeEngineConfiguration):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
kind: Literal[SecPipeSandboxEngines.PODMAN] = SecPipeSandboxEngines.PODMAN
|
||||
|
||||
#: TODO.
|
||||
socket: str
|
||||
|
||||
def into_engine(self) -> AbstractSecPipeSandboxEngine:
|
||||
"""TODO."""
|
||||
return Podman(socket=self.socket)
|
||||
@@ -0,0 +1,557 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path, PurePath
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from podman.errors import ImageNotFound
|
||||
|
||||
from secpipe_common.exceptions import SecPipeError
|
||||
from secpipe_common.sandboxes.engines.base.engine import AbstractSecPipeSandboxEngine, ImageInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from podman import PodmanClient
|
||||
from podman.domain.containers import Container
|
||||
from structlog.stdlib import BoundLogger
|
||||
|
||||
|
||||
def get_logger() -> BoundLogger:
|
||||
"""TODO."""
|
||||
from structlog import get_logger # noqa: PLC0415 (required by temporal)
|
||||
|
||||
return cast("BoundLogger", get_logger())
|
||||
|
||||
|
||||
class Podman(AbstractSecPipeSandboxEngine):
|
||||
"""TODO."""
|
||||
|
||||
#: TODO.
|
||||
__socket: str
|
||||
|
||||
def __init__(self, socket: str) -> None:
|
||||
"""Initialize an instance of the class.
|
||||
|
||||
:param socket: TODO.
|
||||
|
||||
"""
|
||||
AbstractSecPipeSandboxEngine.__init__(self)
|
||||
self.__socket = socket
|
||||
|
||||
def get_client(self) -> PodmanClient:
|
||||
"""TODO.
|
||||
|
||||
:returns TODO.
|
||||
|
||||
"""
|
||||
from podman import PodmanClient # noqa: PLC0415 (required by temporal)
|
||||
|
||||
return PodmanClient(base_url=self.__socket)
|
||||
|
||||
def list_images(self, filter_prefix: str | None = None) -> list[ImageInfo]:
|
||||
"""List available container images.
|
||||
|
||||
:param filter_prefix: Optional prefix to filter images (e.g., "localhost/").
|
||||
:returns: List of ImageInfo objects for available images.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
images: list[ImageInfo] = []
|
||||
|
||||
with client:
|
||||
for image in client.images.list():
|
||||
# Get all tags for this image
|
||||
tags = image.tags or []
|
||||
for tag in tags:
|
||||
# Apply filter if specified
|
||||
if filter_prefix and not tag.startswith(filter_prefix):
|
||||
continue
|
||||
|
||||
# Parse repository and tag
|
||||
if ":" in tag:
|
||||
repo, tag_name = tag.rsplit(":", 1)
|
||||
else:
|
||||
repo = tag
|
||||
tag_name = "latest"
|
||||
|
||||
images.append(
|
||||
ImageInfo(
|
||||
reference=tag,
|
||||
repository=repo,
|
||||
tag=tag_name,
|
||||
image_id=image.short_id if hasattr(image, "short_id") else image.id[:12],
|
||||
size=image.attrs.get("Size") if hasattr(image, "attrs") else None,
|
||||
)
|
||||
)
|
||||
|
||||
get_logger().debug("listed images", count=len(images), filter_prefix=filter_prefix)
|
||||
return images
|
||||
|
||||
def register_archive(self, archive: Path, repository: str) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param archive: TODO.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
images = list(client.images.load(file_path=archive))
|
||||
if len(images) != 1:
|
||||
message: str = "expected only one image"
|
||||
raise SecPipeError(message)
|
||||
image = images[0]
|
||||
image.tag(repository=repository, tag="latest")
|
||||
|
||||
def spawn_sandbox(self, image: str) -> str:
|
||||
"""Spawn a sandbox based on the given image.
|
||||
|
||||
:param image: The image the sandbox should be based on.
|
||||
:returns: The sandbox identifier.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.create(image=image)
|
||||
container_identifier: str = container.id
|
||||
get_logger().debug("create podman container", container_identifier=container_identifier)
|
||||
return container_identifier
|
||||
|
||||
def push_archive_to_sandbox(self, identifier: str, source: Path, destination: PurePath) -> None:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:param destination: TODO.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug(
|
||||
"push archive to podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
# reading everything at once for now, even though this temporary solution is not viable with large files,
|
||||
# since the podman sdk does not currently expose a way to chunk uploads.
|
||||
# in order to fix this issue, we could directly interact with the podman rest api or make a contribution
|
||||
# to the podman sdk in order to allow the 'put_archive' method to support chunked uploads.
|
||||
data: bytes = source.read_bytes()
|
||||
container.put_archive(path=str(destination), data=data)
|
||||
|
||||
def start_sandbox(self, identifier: str) -> None:
|
||||
"""Start the sandbox matching the given identifier.
|
||||
|
||||
:param identifier: The identifier of the sandbox to start.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug(
|
||||
"start podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
container.start()
|
||||
|
||||
def execute_inside_sandbox(self, identifier: str, command: list[str]) -> None:
|
||||
"""Execute a command inside the sandbox matching the given identifier and wait for completion.
|
||||
|
||||
:param sandbox: The identifier of the sandbox.
|
||||
:param command: The command to run.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug(
|
||||
"executing command inside podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
(status, (stdout, stderr)) = container.exec_run(cmd=command, demux=True)
|
||||
get_logger().debug(
|
||||
"command execution result",
|
||||
status=status,
|
||||
stdout_size=len(stdout) if stdout else 0,
|
||||
stderr_size=len(stderr) if stderr else 0,
|
||||
)
|
||||
|
||||
def pull_archive_from_sandbox(self, identifier: str, source: PurePath) -> Path:
|
||||
"""TODO.
|
||||
|
||||
:param identifier: TODO.
|
||||
:param source: TODO.
|
||||
:returns: TODO.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug(
|
||||
"pull archive from podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
with NamedTemporaryFile(delete=False, delete_on_close=False) as file:
|
||||
stream, _stat = container.get_archive(path=str(source))
|
||||
for chunk in stream:
|
||||
file.write(chunk)
|
||||
get_logger().debug(
|
||||
"created archive",
|
||||
archive=file.name,
|
||||
)
|
||||
return Path(file.name)
|
||||
|
||||
def terminate_sandbox(self, identifier: str) -> None:
|
||||
"""Terminate the sandbox matching the given identifier.
|
||||
|
||||
:param identifier: The identifier of the sandbox to terminate.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug(
|
||||
"kill podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
# Only kill running containers; for created/stopped, skip to remove
|
||||
if container.status in ("running", "paused"):
|
||||
container.kill()
|
||||
get_logger().debug(
|
||||
"remove podman container",
|
||||
container_identifier=identifier,
|
||||
container_status=container.status,
|
||||
)
|
||||
container.remove()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Extended Container Operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def image_exists(self, image: str) -> bool:
|
||||
"""Check if a container image exists locally.
|
||||
|
||||
:param image: Full image reference (e.g., "localhost/module:latest").
|
||||
:returns: True if image exists, False otherwise.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
try:
|
||||
client.images.get(name=image)
|
||||
except ImageNotFound:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def pull_image(self, image: str, timeout: int = 300) -> None:
|
||||
"""Pull an image from a container registry.
|
||||
|
||||
:param image: Full image reference to pull.
|
||||
:param timeout: Timeout in seconds for the pull operation.
|
||||
:raises SecPipeError: If pull fails.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
try:
|
||||
get_logger().info("pulling image", image=image)
|
||||
client.images.pull(repository=image)
|
||||
get_logger().info("image pulled successfully", image=image)
|
||||
except Exception as exc:
|
||||
message = f"Failed to pull image '{image}': {exc}"
|
||||
raise SecPipeError(message) from exc
|
||||
|
||||
def tag_image(self, source: str, target: str) -> None:
|
||||
"""Tag an image with a new name.
|
||||
|
||||
:param source: Source image reference.
|
||||
:param target: Target image reference.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
image = client.images.get(name=source)
|
||||
# Parse target into repository and tag
|
||||
if ":" in target:
|
||||
repo, tag = target.rsplit(":", 1)
|
||||
else:
|
||||
repo = target
|
||||
tag = "latest"
|
||||
image.tag(repository=repo, tag=tag)
|
||||
get_logger().debug("tagged image", source=source, target=target)
|
||||
|
||||
def create_container(
|
||||
self,
|
||||
image: str,
|
||||
volumes: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Create a container from an image.
|
||||
|
||||
:param image: Image to create container from.
|
||||
:param volumes: Optional volume mappings {host_path: container_path}.
|
||||
:returns: Container identifier.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
# Build volume mounts in podman format
|
||||
mounts = []
|
||||
if volumes:
|
||||
for host_path, container_path in volumes.items():
|
||||
mounts.append({"type": "bind", "source": host_path, "target": container_path, "read_only": True})
|
||||
|
||||
container: Container = client.containers.create(image=image, mounts=mounts if mounts else None)
|
||||
container_id: str = str(container.id)
|
||||
get_logger().debug("created container", container_id=container_id, image=image)
|
||||
return container_id
|
||||
|
||||
def start_container_attached(
|
||||
self,
|
||||
identifier: str,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Start a container and wait for it to complete.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Timeout in seconds for execution.
|
||||
:returns: Tuple of (exit_code, stdout, stderr).
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
get_logger().debug("starting container attached", container_id=identifier)
|
||||
|
||||
# Start the container
|
||||
container.start()
|
||||
|
||||
# Wait for completion with timeout
|
||||
result = container.wait(timeout=timeout)
|
||||
exit_code: int = result.get("StatusCode", -1) if isinstance(result, dict) else int(result)
|
||||
|
||||
# Get logs
|
||||
stdout_raw = container.logs(stdout=True, stderr=False)
|
||||
stderr_raw = container.logs(stdout=False, stderr=True)
|
||||
|
||||
# Decode if bytes
|
||||
stdout_str: str = ""
|
||||
stderr_str: str = ""
|
||||
if isinstance(stdout_raw, bytes):
|
||||
stdout_str = stdout_raw.decode("utf-8", errors="replace")
|
||||
elif isinstance(stdout_raw, str):
|
||||
stdout_str = stdout_raw
|
||||
if isinstance(stderr_raw, bytes):
|
||||
stderr_str = stderr_raw.decode("utf-8", errors="replace")
|
||||
elif isinstance(stderr_raw, str):
|
||||
stderr_str = stderr_raw
|
||||
|
||||
get_logger().debug("container finished", container_id=identifier, exit_code=exit_code)
|
||||
return (exit_code, stdout_str, stderr_str)
|
||||
|
||||
def copy_to_container(self, identifier: str, source: Path, destination: str) -> None:
|
||||
"""Copy a file or directory to a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path on host.
|
||||
:param destination: Destination path in container.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
|
||||
# Create tar archive in memory
|
||||
tar_buffer = BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
||||
tar.add(str(source), arcname=Path(source).name)
|
||||
tar_buffer.seek(0)
|
||||
|
||||
# Use put_archive to copy
|
||||
container.put_archive(path=destination, data=tar_buffer.read())
|
||||
get_logger().debug("copied to container", source=str(source), destination=destination)
|
||||
|
||||
def copy_from_container(self, identifier: str, source: str, destination: Path) -> None:
|
||||
"""Copy a file or directory from a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param source: Source path in container.
|
||||
:param destination: Destination path on host.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
|
||||
# Get archive from container
|
||||
stream, _stat = container.get_archive(path=source)
|
||||
|
||||
# Write to temp file and extract
|
||||
tar_buffer = BytesIO()
|
||||
for chunk in stream:
|
||||
tar_buffer.write(chunk)
|
||||
tar_buffer.seek(0)
|
||||
|
||||
# Extract to destination
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
|
||||
tar.extractall(path=destination) # noqa: S202 (trusted source)
|
||||
|
||||
get_logger().debug("copied from container", source=source, destination=str(destination))
|
||||
|
||||
def remove_container(self, identifier: str, *, force: bool = False) -> None:
|
||||
"""Remove a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param force: Force removal even if running.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
if force and container.status in ("running", "paused"):
|
||||
container.kill()
|
||||
container.remove()
|
||||
get_logger().debug("removed container", container_id=identifier)
|
||||
|
||||
def start_container(self, identifier: str) -> None:
|
||||
"""Start a container without waiting for it to complete.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
container.start()
|
||||
get_logger().debug("started container (detached)", container_id=identifier)
|
||||
|
||||
def get_container_status(self, identifier: str) -> str:
|
||||
"""Get the status of a container.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:returns: Container status (e.g., "running", "exited", "created").
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
return str(container.status)
|
||||
|
||||
def stop_container(self, identifier: str, timeout: int = 10) -> None:
|
||||
"""Stop a running container gracefully.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param timeout: Seconds to wait before killing.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
if container.status == "running":
|
||||
container.stop(timeout=timeout)
|
||||
get_logger().debug("stopped container", container_id=identifier)
|
||||
|
||||
def read_file_from_container(self, identifier: str, path: str) -> str:
|
||||
"""Read a file from inside a running container using exec.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
(status, (stdout, stderr)) = container.exec_run(cmd=["cat", path], demux=True)
|
||||
if status != 0:
|
||||
error_msg = stderr.decode("utf-8", errors="replace") if stderr else "File not found"
|
||||
get_logger().debug("failed to read file from container", path=path, error=error_msg)
|
||||
return ""
|
||||
return stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
|
||||
def tail_file_from_container(self, identifier: str, path: str, start_line: int = 1) -> str:
|
||||
"""Read a file from a container starting at a given line number.
|
||||
|
||||
:param identifier: Container identifier.
|
||||
:param path: Path to file inside container.
|
||||
:param start_line: 1-based line number to start reading from.
|
||||
:returns: File contents from *start_line* onwards.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
container: Container = client.containers.get(key=identifier)
|
||||
(status, (stdout, stderr)) = container.exec_run(
|
||||
cmd=["tail", "-n", f"+{start_line}", path],
|
||||
demux=True,
|
||||
)
|
||||
if status != 0:
|
||||
error_msg = stderr.decode("utf-8", errors="replace") if stderr else "File not found"
|
||||
get_logger().debug(
|
||||
"failed to tail file from container", path=path, start_line=start_line, error=error_msg,
|
||||
)
|
||||
return ""
|
||||
return stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
|
||||
def list_containers(self, all_containers: bool = True) -> list[dict]:
|
||||
"""List containers.
|
||||
|
||||
:param all_containers: Include stopped containers.
|
||||
:returns: List of container info dicts.
|
||||
|
||||
"""
|
||||
client: PodmanClient = self.get_client()
|
||||
with client:
|
||||
containers = client.containers.list(all=all_containers)
|
||||
return [
|
||||
{
|
||||
"Id": str(c.id),
|
||||
"Names": [c.name] if hasattr(c, "name") else [],
|
||||
"Status": str(c.status),
|
||||
"Image": str(c.image) if hasattr(c, "image") else "",
|
||||
}
|
||||
for c in containers
|
||||
]
|
||||
|
||||
def read_file_from_image(self, image: str, path: str) -> str:
|
||||
"""Read a file from inside an image without starting a long-running container.
|
||||
|
||||
Creates a temporary container, reads the file, and removes the container.
|
||||
|
||||
:param image: Image reference (e.g., "secpipe-rust-analyzer:latest").
|
||||
:param path: Path to file inside image.
|
||||
:returns: File contents as string.
|
||||
|
||||
"""
|
||||
logger = get_logger()
|
||||
client: PodmanClient = self.get_client()
|
||||
|
||||
with client:
|
||||
try:
|
||||
# Create a container that just runs cat on the file
|
||||
container = client.containers.create(
|
||||
image=image,
|
||||
command=["cat", path],
|
||||
remove=True,
|
||||
)
|
||||
|
||||
# Start it and wait for completion
|
||||
container.start()
|
||||
container.wait()
|
||||
|
||||
# Get the logs (which contain stdout)
|
||||
output = container.logs(stdout=True, stderr=False)
|
||||
|
||||
if isinstance(output, bytes):
|
||||
return output.decode("utf-8", errors="replace")
|
||||
return str(output)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("failed to read file from image", image=image, path=path, error=str(exc))
|
||||
return ""
|
||||
Reference in New Issue
Block a user