fix: use SNAP detection for podman storage, update tests for OSS

This commit is contained in:
AFredefon
2026-01-30 11:58:38 +01:00
parent 5d300e5366
commit aea50ac42a
7 changed files with 258 additions and 54 deletions

View File

@@ -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:

View File

@@ -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]

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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",