mirror of
https://github.com/FuzzingLabs/fuzzforge_ai.git
synced 2026-05-26 22:42:24 +02:00
feat: FuzzForge AI - complete rewrite for OSS release
This commit is contained in:
@@ -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
|
||||
]
|
||||
Reference in New Issue
Block a user