diff --git a/Makefile b/Makefile index 6c92c16..06f0184 100644 --- a/Makefile +++ b/Makefile @@ -65,23 +65,29 @@ test: done # Build all module container images +# Under Snap: uses self-contained storage at ~/.fuzzforge/containers/ +# Otherwise: uses default podman storage (works on native Linux + macOS) build-modules: @echo "Building FuzzForge module images..." - @echo "This uses self-contained storage at ~/.fuzzforge/containers/" - @for module in fuzzforge-modules/*/; do \ + @if [ -n "$$SNAP" ]; then \ + echo "Detected Snap environment - using isolated storage at ~/.fuzzforge/containers/"; \ + PODMAN_CMD="podman --root ~/.fuzzforge/containers/storage --runroot ~/.fuzzforge/containers/run"; \ + else \ + echo "Using default podman storage"; \ + PODMAN_CMD="podman"; \ + fi; \ + for module in fuzzforge-modules/*/; do \ if [ -f "$$module/Dockerfile" ] && \ [ "$$module" != "fuzzforge-modules/fuzzforge-modules-sdk/" ] && \ [ "$$module" != "fuzzforge-modules/fuzzforge-module-template/" ]; then \ name=$$(basename $$module); \ version=$$(grep 'version' "$$module/pyproject.toml" 2>/dev/null | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "0.1.0"); \ echo "Building $$name:$$version..."; \ - podman --root ~/.fuzzforge/containers/storage --runroot ~/.fuzzforge/containers/run \ - build -t "fuzzforge-$$name:$$version" "$$module" || exit 1; \ + $$PODMAN_CMD build -t "fuzzforge-$$name:$$version" "$$module" || exit 1; \ fi \ done @echo "" @echo "✓ All modules built successfully!" - @echo " Images stored in: ~/.fuzzforge/containers/storage" # Clean build artifacts clean: diff --git a/fuzzforge-common/pyproject.toml b/fuzzforge-common/pyproject.toml index 4a2f96c..477f179 100644 --- a/fuzzforge-common/pyproject.toml +++ b/fuzzforge-common/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "fuzzforge-types==0.0.1", "podman==5.6.0", "pydantic==2.12.4", + "structlog>=24.0.0", ] [project.optional-dependencies] diff --git a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py index 24dfa2f..dfd29f3 100644 --- a/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py +++ b/fuzzforge-common/src/fuzzforge_common/sandboxes/engines/podman/cli.py @@ -29,44 +29,79 @@ def get_logger() -> BoundLogger: return cast("BoundLogger", get_logger()) +def _is_running_under_snap() -> bool: + """Check if running under Snap environment. + + VS Code installed via Snap sets XDG_DATA_HOME to a version-specific path, + causing Podman to look for storage in non-standard locations. When SNAP + is set, we use custom storage paths to ensure consistency. + + Note: Snap only exists on Linux, so this also handles macOS implicitly. + """ + import os # noqa: PLC0415 + return os.getenv("SNAP") is not None + + class PodmanCLI(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. + and --runroot flags when running under Snap, providing isolation from + system Podman storage. + + The custom storage is only used when: + 1. Running under Snap (SNAP env var is set) - to fix XDG_DATA_HOME issues + 2. Custom paths are explicitly provided + + Otherwise, uses default Podman storage which works for: + - Native Linux installations + - macOS (where Podman runs in a VM via podman machine) """ - __graphroot: Path - __runroot: Path + __graphroot: Path | None + __runroot: Path | None + __use_custom_storage: bool - def __init__(self, graphroot: Path, runroot: Path) -> None: + def __init__(self, graphroot: Path | None = None, runroot: Path | None = None) -> None: """Initialize the PodmanCLI engine. :param graphroot: Path to container image storage. :param runroot: Path to container runtime state. + Custom storage is used when running under Snap AND paths are provided. """ 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) + + # Use custom storage only under Snap (to fix XDG_DATA_HOME issues) + self.__use_custom_storage = ( + _is_running_under_snap() + and graphroot is not None + and runroot is not None + ) + + if self.__use_custom_storage: + self.__graphroot = graphroot + self.__runroot = runroot + # Ensure directories exist + self.__graphroot.mkdir(parents=True, exist_ok=True) + self.__runroot.mkdir(parents=True, exist_ok=True) + else: + self.__graphroot = None + self.__runroot = None def _base_cmd(self) -> list[str]: """Get base Podman command with storage flags. - :returns: Base command list with --root and --runroot. + :returns: Base command list, with --root and --runroot only under Snap. """ - return [ - "podman", - "--root", str(self.__graphroot), - "--runroot", str(self.__runroot), - ] + if self.__use_custom_storage and self.__graphroot and self.__runroot: + return [ + "podman", + "--root", str(self.__graphroot), + "--runroot", str(self.__runroot), + ] + return ["podman"] def _run(self, args: list[str], *, check: bool = True, capture: bool = True) -> subprocess.CompletedProcess: """Run a Podman command. diff --git a/fuzzforge-common/tests/unit/engines/conftest.py b/fuzzforge-common/tests/unit/engines/conftest.py index 6baa337..f12b122 100644 --- a/fuzzforge-common/tests/unit/engines/conftest.py +++ b/fuzzforge-common/tests/unit/engines/conftest.py @@ -1,9 +1,3 @@ -import pytest +"""Conftest for engine tests.""" -from fuzzforge_common.sandboxes.engines.podman.engine import Podman - - -@pytest.fixture -def podman_engine(podman_socket: str) -> Podman: - """TODO.""" - return Podman(socket=podman_socket) +# No special fixtures needed - PodmanCLI tests use tmp_path directly diff --git a/fuzzforge-common/tests/unit/engines/test_podman.py b/fuzzforge-common/tests/unit/engines/test_podman.py index e777039..5dd5db3 100644 --- a/fuzzforge-common/tests/unit/engines/test_podman.py +++ b/fuzzforge-common/tests/unit/engines/test_podman.py @@ -1,21 +1,130 @@ -from typing import TYPE_CHECKING -from uuid import uuid4 +"""Tests for the PodmanCLI engine (OSS container engine).""" -if TYPE_CHECKING: - from pathlib import Path +import os +import shutil +import uuid +from pathlib import Path +from unittest import mock - from podman import PodmanClient +import pytest - from fuzzforge_common.sandboxes.engines.podman.engine import Podman +from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI, _is_running_under_snap -def test_can_register_oci( - path_to_oci: Path, - podman_engine: Podman, - podman_client: PodmanClient, -) -> None: - """TODO.""" - repository: str = str(uuid4()) - podman_engine.register_archive(archive=path_to_oci, repository=repository) - assert podman_client.images.exists(key=repository) - podman_client.images.get(name=repository).remove() +@pytest.fixture +def podman_cli_engine() -> PodmanCLI: + """Create a PodmanCLI engine with temporary storage. + + Uses short paths in /tmp to avoid podman's 50-char runroot limit. + Simulates Snap environment to test custom storage paths. + """ + short_id = str(uuid.uuid4())[:8] + graphroot = Path(f"/tmp/ff-{short_id}/storage") + runroot = Path(f"/tmp/ff-{short_id}/run") + + # Simulate Snap environment for testing + with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + engine = PodmanCLI(graphroot=graphroot, runroot=runroot) + + yield engine + + # Cleanup + parent = graphroot.parent + if parent.exists(): + shutil.rmtree(parent, ignore_errors=True) + + +def test_snap_detection_when_snap_set() -> None: + """Test that SNAP environment is detected.""" + with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + assert _is_running_under_snap() is True + + +def test_snap_detection_when_snap_not_set() -> None: + """Test that non-Snap environment is detected.""" + env = os.environ.copy() + env.pop("SNAP", None) + with mock.patch.dict(os.environ, env, clear=True): + assert _is_running_under_snap() is False + + +def test_podman_cli_creates_storage_directories_under_snap() -> None: + """Test that PodmanCLI creates storage directories when under Snap.""" + short_id = str(uuid.uuid4())[:8] + graphroot = Path(f"/tmp/ff-{short_id}/storage") + runroot = Path(f"/tmp/ff-{short_id}/run") + + assert not graphroot.exists() + assert not runroot.exists() + + with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + engine = PodmanCLI(graphroot=graphroot, runroot=runroot) + + assert graphroot.exists() + assert runroot.exists() + + # Cleanup + shutil.rmtree(graphroot.parent, ignore_errors=True) + + +def test_podman_cli_base_cmd_under_snap() -> None: + """Test that base command includes --root/--runroot under Snap.""" + short_id = str(uuid.uuid4())[:8] + graphroot = Path(f"/tmp/ff-{short_id}/storage") + runroot = Path(f"/tmp/ff-{short_id}/run") + + with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): + engine = PodmanCLI(graphroot=graphroot, runroot=runroot) + base_cmd = engine._base_cmd() + + assert "podman" in base_cmd + assert "--root" in base_cmd + assert "--runroot" in base_cmd + + # Cleanup + shutil.rmtree(graphroot.parent, ignore_errors=True) + + +def test_podman_cli_base_cmd_without_snap() -> None: + """Test that base command is plain 'podman' when not under Snap.""" + short_id = str(uuid.uuid4())[:8] + graphroot = Path(f"/tmp/ff-{short_id}/storage") + runroot = Path(f"/tmp/ff-{short_id}/run") + + env = os.environ.copy() + env.pop("SNAP", None) + with mock.patch.dict(os.environ, env, clear=True): + engine = PodmanCLI(graphroot=graphroot, runroot=runroot) + base_cmd = engine._base_cmd() + + assert base_cmd == ["podman"] + assert "--root" not in base_cmd + + # Directories should NOT be created when not under Snap + assert not graphroot.exists() + + +def test_podman_cli_default_mode() -> None: + """Test PodmanCLI without custom storage paths.""" + engine = PodmanCLI() # No paths provided + base_cmd = engine._base_cmd() + + assert base_cmd == ["podman"] + assert "--root" not in base_cmd + + +def test_podman_cli_list_images_returns_list(podman_cli_engine: PodmanCLI) -> None: + """Test that list_images returns a list (even if empty).""" + images = podman_cli_engine.list_images() + + assert isinstance(images, list) + + +@pytest.mark.skip(reason="Requires pulling images, slow integration test") +def test_podman_cli_can_pull_and_list_image(podman_cli_engine: PodmanCLI) -> None: + """Test pulling an image and listing it.""" + # Pull a small image + podman_cli_engine._run(["pull", "docker.io/library/alpine:latest"]) + + images = podman_cli_engine.list_images() + assert any("alpine" in img.identifier for img in images) diff --git a/fuzzforge-mcp/tests/test_resources.py b/fuzzforge-mcp/tests/test_resources.py index c506a7b..370ffff 100644 --- a/fuzzforge-mcp/tests/test_resources.py +++ b/fuzzforge-mcp/tests/test_resources.py @@ -1,13 +1,61 @@ -"""MCP resource tests for FuzzForge OSS. +"""MCP tool tests for FuzzForge OSS. -Note: The OSS version uses a different architecture than the enterprise version. -These tests are placeholders - the actual MCP tools are tested through integration tests. +Tests the MCP tools that are available in the OSS version. """ import pytest +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastmcp import Client + from fastmcp.client import FastMCPTransport -@pytest.mark.skip(reason="OSS uses different architecture - no HTTP API") -async def test_placeholder() -> None: - """Placeholder test - OSS MCP doesn't use HTTP resources.""" - pass +async def test_list_modules_tool_exists( + mcp_client: "Client[FastMCPTransport]", +) -> None: + """Test that the list_modules tool is available.""" + tools = await mcp_client.list_tools() + tool_names = [tool.name for tool in tools] + + assert "list_modules" in tool_names + + +async def test_init_project_tool_exists( + mcp_client: "Client[FastMCPTransport]", +) -> None: + """Test that the init_project tool is available.""" + tools = await mcp_client.list_tools() + tool_names = [tool.name for tool in tools] + + assert "init_project" in tool_names + + +async def test_execute_module_tool_exists( + mcp_client: "Client[FastMCPTransport]", +) -> None: + """Test that the execute_module tool is available.""" + tools = await mcp_client.list_tools() + tool_names = [tool.name for tool in tools] + + assert "execute_module" in tool_names + + +async def test_execute_workflow_tool_exists( + mcp_client: "Client[FastMCPTransport]", +) -> None: + """Test that the execute_workflow tool is available.""" + tools = await mcp_client.list_tools() + tool_names = [tool.name for tool in tools] + + assert "execute_workflow" in tool_names + + +async def test_mcp_has_expected_tool_count( + mcp_client: "Client[FastMCPTransport]", +) -> None: + """Test that MCP has the expected number of tools.""" + tools = await mcp_client.list_tools() + + # Should have at least 4 core tools + assert len(tools) >= 4 diff --git a/pyproject.toml b/pyproject.toml index bac9374..555c5b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,17 @@ authors = [ { name = "FuzzingLabs", email = "contact@fuzzinglabs.com" } ] +[project.optional-dependencies] +dev = [ + "pytest==9.0.2", + "pytest-asyncio==1.3.0", + "pytest-httpx==0.36.0", + "fuzzforge-tests", + "fuzzforge-common", + "fuzzforge-types", + "fuzzforge-mcp", +] + [tool.uv.workspace] members = [ "fuzzforge-common",