feat: FuzzForge AI - complete rewrite for OSS release

This commit is contained in:
AFredefon
2026-01-30 09:57:48 +01:00
commit b46f050aef
226 changed files with 12943 additions and 0 deletions
@@ -0,0 +1,23 @@
"""FuzzForge sandbox abstractions and implementations."""
from fuzzforge_common.sandboxes.engines import (
AbstractFuzzForgeEngineConfiguration,
AbstractFuzzForgeSandboxEngine,
Docker,
DockerConfiguration,
FuzzForgeSandboxEngines,
ImageInfo,
Podman,
PodmanConfiguration,
)
__all__ = [
"AbstractFuzzForgeEngineConfiguration",
"AbstractFuzzForgeSandboxEngine",
"Docker",
"DockerConfiguration",
"FuzzForgeSandboxEngines",
"ImageInfo",
"Podman",
"PodmanConfiguration",
]
@@ -0,0 +1,21 @@
"""Container engine implementations for FuzzForge sandboxes."""
from fuzzforge_common.sandboxes.engines.base import (
AbstractFuzzForgeEngineConfiguration,
AbstractFuzzForgeSandboxEngine,
ImageInfo,
)
from fuzzforge_common.sandboxes.engines.docker import Docker, DockerConfiguration
from fuzzforge_common.sandboxes.engines.enumeration import FuzzForgeSandboxEngines
from fuzzforge_common.sandboxes.engines.podman import Podman, PodmanConfiguration
__all__ = [
"AbstractFuzzForgeEngineConfiguration",
"AbstractFuzzForgeSandboxEngine",
"Docker",
"DockerConfiguration",
"FuzzForgeSandboxEngines",
"ImageInfo",
"Podman",
"PodmanConfiguration",
]
@@ -0,0 +1,15 @@
"""Base engine abstractions."""
from fuzzforge_common.sandboxes.engines.base.configuration import (
AbstractFuzzForgeEngineConfiguration,
)
from fuzzforge_common.sandboxes.engines.base.engine import (
AbstractFuzzForgeSandboxEngine,
ImageInfo,
)
__all__ = [
"AbstractFuzzForgeEngineConfiguration",
"AbstractFuzzForgeSandboxEngine",
"ImageInfo",
]
@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from pydantic import BaseModel
from fuzzforge_common.sandboxes.engines.enumeration import (
FuzzForgeSandboxEngines, # noqa: TC001 (required by 'pydantic' at runtime)
)
if TYPE_CHECKING:
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine
class AbstractFuzzForgeEngineConfiguration(ABC, BaseModel):
"""TODO."""
#: TODO.
kind: FuzzForgeSandboxEngines
@abstractmethod
def into_engine(self) -> AbstractFuzzForgeSandboxEngine:
"""TODO."""
message: str = f"method 'into_engine' is not implemented for class '{self.__class__.__name__}'"
raise NotImplementedError(message)
@@ -0,0 +1,281 @@
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/fuzzforge-module-echidna:latest").
reference: str
#: Repository name (e.g., "localhost/fuzzforge-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
class AbstractFuzzForgeSandboxEngine(ABC):
"""Abstract class used as a base for all FuzzForge 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 FuzzForgeError: 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 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)
@@ -0,0 +1,11 @@
"""Docker container engine implementation."""
from fuzzforge_common.sandboxes.engines.docker.configuration import (
DockerConfiguration,
)
from fuzzforge_common.sandboxes.engines.docker.engine import Docker
__all__ = [
"Docker",
"DockerConfiguration",
]
@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING, Literal
from fuzzforge_common.sandboxes.engines.base.configuration import AbstractFuzzForgeEngineConfiguration
from fuzzforge_common.sandboxes.engines.docker.engine import Docker
from fuzzforge_common.sandboxes.engines.enumeration import FuzzForgeSandboxEngines
if TYPE_CHECKING:
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine
class DockerConfiguration(AbstractFuzzForgeEngineConfiguration):
"""TODO."""
#: TODO.
kind: Literal[FuzzForgeSandboxEngines.DOCKER] = FuzzForgeSandboxEngines.DOCKER
#: TODO.
socket: str
def into_engine(self) -> AbstractFuzzForgeSandboxEngine:
"""TODO."""
return Docker(socket=self.socket)
@@ -0,0 +1,174 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine, ImageInfo
if TYPE_CHECKING:
from pathlib import Path, PurePath
class Docker(AbstractFuzzForgeSandboxEngine):
"""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 list_containers(self, all_containers: bool = True) -> list[dict]:
"""List containers."""
message: str = "Docker engine list_containers is not yet implemented"
raise NotImplementedError(message)
@@ -0,0 +1,11 @@
from enum import StrEnum
class FuzzForgeSandboxEngines(StrEnum):
"""TODO."""
#: TODO.
DOCKER = "docker"
#: TODO.
PODMAN = "podman"
@@ -0,0 +1,13 @@
"""Podman container engine implementation."""
from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI
from fuzzforge_common.sandboxes.engines.podman.configuration import (
PodmanConfiguration,
)
from fuzzforge_common.sandboxes.engines.podman.engine import Podman
__all__ = [
"Podman",
"PodmanCLI",
"PodmanConfiguration",
]
@@ -0,0 +1,444 @@
"""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 fuzzforge_common.exceptions import FuzzForgeError
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine, 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 PodmanCLI(AbstractFuzzForgeSandboxEngine):
"""Podman engine using CLI with custom storage paths.
This implementation uses subprocess calls to the Podman CLI with --root
and --runroot flags, providing isolation from system Podman storage.
This is particularly useful when running from VS Code snap which sets
XDG_DATA_HOME to a version-specific path.
"""
__graphroot: Path
__runroot: Path
def __init__(self, graphroot: Path, runroot: Path) -> None:
"""Initialize the PodmanCLI engine.
:param graphroot: Path to container image storage.
:param runroot: Path to container runtime state.
"""
AbstractFuzzForgeSandboxEngine.__init__(self)
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)
def _base_cmd(self) -> list[str]:
"""Get base Podman command with storage flags.
:returns: Base command list with --root and --runroot.
"""
return [
"podman",
"--root", str(self.__graphroot),
"--runroot", str(self.__runroot),
]
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"
images.append(
ImageInfo(
reference=name,
repository=repo,
tag=tag,
image_id=image.get("Id", "")[:12],
size=image.get("Size"),
)
)
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 FuzzForgeError(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 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 []
# -------------------------------------------------------------------------
# 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 fuzzforge_common.sandboxes.engines.base.configuration import AbstractFuzzForgeEngineConfiguration
from fuzzforge_common.sandboxes.engines.enumeration import FuzzForgeSandboxEngines
from fuzzforge_common.sandboxes.engines.podman.engine import Podman
if TYPE_CHECKING:
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine
class PodmanConfiguration(AbstractFuzzForgeEngineConfiguration):
"""TODO."""
#: TODO.
kind: Literal[FuzzForgeSandboxEngines.PODMAN] = FuzzForgeSandboxEngines.PODMAN
#: TODO.
socket: str
def into_engine(self) -> AbstractFuzzForgeSandboxEngine:
"""TODO."""
return Podman(socket=self.socket)
@@ -0,0 +1,496 @@
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 fuzzforge_common.exceptions import FuzzForgeError
from fuzzforge_common.sandboxes.engines.base.engine import AbstractFuzzForgeSandboxEngine, 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(AbstractFuzzForgeSandboxEngine):
"""TODO."""
#: TODO.
__socket: str
def __init__(self, socket: str) -> None:
"""Initialize an instance of the class.
:param socket: TODO.
"""
AbstractFuzzForgeSandboxEngine.__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 FuzzForgeError(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 FuzzForgeError: 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 FuzzForgeError(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 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
]