fix: block Podman on macOS and remove ghcr.io default

- Add platform check in PodmanCLI.__init__() that raises FuzzForgeError
  on macOS with instructions to use Docker instead
- Change RegistrySettings.url default from "ghcr.io/fuzzinglabs" to ""
  (empty string) for local-only mode since no images are published yet
- Update _ensure_module_image() to show helpful error when image not
  found locally and no registry configured
- Update tests to mock Linux platform for Podman tests
- Add root ruff.toml to fix broken configuration in fuzzforge-runner
This commit is contained in:
tduhamel42
2026-01-31 10:27:03 +01:00
parent e72c5fb201
commit ac6e8a8a34
6 changed files with 144 additions and 68 deletions
@@ -96,7 +96,7 @@ class DockerCLI(AbstractFuzzForgeSandboxEngine):
reference = f"{repo}:{tag}" reference = f"{repo}:{tag}"
if filter_prefix and not reference.startswith(filter_prefix): if filter_prefix and filter_prefix not in reference:
continue continue
images.append( images.append(
@@ -39,6 +39,7 @@ def _is_running_under_snap() -> bool:
Note: Snap only exists on Linux, so this also handles macOS implicitly. Note: Snap only exists on Linux, so this also handles macOS implicitly.
""" """
import os # noqa: PLC0415 import os # noqa: PLC0415
return os.getenv("SNAP") is not None return os.getenv("SNAP") is not None
@@ -69,15 +70,23 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
:param runroot: Path to container runtime state. :param runroot: Path to container runtime state.
Custom storage is used when running under Snap AND paths are provided. Custom storage is used when running under Snap AND paths are provided.
:raises FuzzForgeError: If running on macOS (Podman not supported).
""" """
import sys # noqa: PLC0415
if sys.platform == "darwin":
msg = (
"Podman is not supported on macOS. Please use Docker instead:\n"
" brew install --cask docker\n"
" # Or download from https://docker.com/products/docker-desktop"
)
raise FuzzForgeError(msg)
AbstractFuzzForgeSandboxEngine.__init__(self) AbstractFuzzForgeSandboxEngine.__init__(self)
# Use custom storage only under Snap (to fix XDG_DATA_HOME issues) # Use custom storage only under Snap (to fix XDG_DATA_HOME issues)
self.__use_custom_storage = ( self.__use_custom_storage = _is_running_under_snap() and graphroot is not None and runroot is not None
_is_running_under_snap()
and graphroot is not None
and runroot is not None
)
if self.__use_custom_storage: if self.__use_custom_storage:
self.__graphroot = graphroot self.__graphroot = graphroot
@@ -98,8 +107,10 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
if self.__use_custom_storage and self.__graphroot and self.__runroot: if self.__use_custom_storage and self.__graphroot and self.__runroot:
return [ return [
"podman", "podman",
"--root", str(self.__graphroot), "--root",
"--runroot", str(self.__runroot), str(self.__graphroot),
"--runroot",
str(self.__runroot),
] ]
return ["podman"] return ["podman"]
@@ -2,28 +2,37 @@
import os import os
import shutil import shutil
import sys
import uuid import uuid
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import pytest import pytest
from fuzzforge_common.exceptions import FuzzForgeError
from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI, _is_running_under_snap from fuzzforge_common.sandboxes.engines.podman.cli import PodmanCLI, _is_running_under_snap
# Helper to mock Linux platform for testing (since Podman is Linux-only)
def _mock_linux_platform() -> mock._patch[str]:
"""Context manager to mock sys.platform as 'linux'."""
return mock.patch.object(sys, "platform", "linux")
@pytest.fixture @pytest.fixture
def podman_cli_engine() -> PodmanCLI: def podman_cli_engine() -> PodmanCLI:
"""Create a PodmanCLI engine with temporary storage. """Create a PodmanCLI engine with temporary storage.
Uses short paths in /tmp to avoid podman's 50-char runroot limit. Uses short paths in /tmp to avoid podman's 50-char runroot limit.
Simulates Snap environment to test custom storage paths. Simulates Snap environment to test custom storage paths.
Mocks Linux platform since Podman is Linux-only.
""" """
short_id = str(uuid.uuid4())[:8] short_id = str(uuid.uuid4())[:8]
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") runroot = Path(f"/tmp/ff-{short_id}/run")
# Simulate Snap environment for testing # Simulate Snap environment for testing on Linux
with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
yield engine yield engine
@@ -48,6 +57,15 @@ def test_snap_detection_when_snap_not_set() -> None:
assert _is_running_under_snap() is False assert _is_running_under_snap() is False
def test_podman_cli_blocks_macos() -> None:
"""Test that PodmanCLI raises error on macOS."""
with mock.patch.object(sys, "platform", "darwin"):
with pytest.raises(FuzzForgeError) as exc_info:
PodmanCLI()
assert "Podman is not supported on macOS" in str(exc_info.value)
assert "Docker" in str(exc_info.value)
def test_podman_cli_creates_storage_directories_under_snap() -> None: def test_podman_cli_creates_storage_directories_under_snap() -> None:
"""Test that PodmanCLI creates storage directories when under Snap.""" """Test that PodmanCLI creates storage directories when under Snap."""
short_id = str(uuid.uuid4())[:8] short_id = str(uuid.uuid4())[:8]
@@ -57,8 +75,8 @@ def test_podman_cli_creates_storage_directories_under_snap() -> None:
assert not graphroot.exists() assert not graphroot.exists()
assert not runroot.exists() assert not runroot.exists()
with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot) PodmanCLI(graphroot=graphroot, runroot=runroot)
assert graphroot.exists() assert graphroot.exists()
assert runroot.exists() assert runroot.exists()
@@ -73,7 +91,7 @@ def test_podman_cli_base_cmd_under_snap() -> None:
graphroot = Path(f"/tmp/ff-{short_id}/storage") graphroot = Path(f"/tmp/ff-{short_id}/storage")
runroot = Path(f"/tmp/ff-{short_id}/run") runroot = Path(f"/tmp/ff-{short_id}/run")
with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}): with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
base_cmd = engine._base_cmd() base_cmd = engine._base_cmd()
@@ -93,7 +111,7 @@ def test_podman_cli_base_cmd_without_snap() -> None:
env = os.environ.copy() env = os.environ.copy()
env.pop("SNAP", None) env.pop("SNAP", None)
with mock.patch.dict(os.environ, env, clear=True): with _mock_linux_platform(), mock.patch.dict(os.environ, env, clear=True):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot) engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
base_cmd = engine._base_cmd() base_cmd = engine._base_cmd()
@@ -106,8 +124,9 @@ def test_podman_cli_base_cmd_without_snap() -> None:
def test_podman_cli_default_mode() -> None: def test_podman_cli_default_mode() -> None:
"""Test PodmanCLI without custom storage paths.""" """Test PodmanCLI without custom storage paths."""
engine = PodmanCLI() # No paths provided with _mock_linux_platform():
base_cmd = engine._base_cmd() engine = PodmanCLI() # No paths provided
base_cmd = engine._base_cmd()
assert base_cmd == ["podman"] assert base_cmd == ["podman"]
assert "--root" not in base_cmd assert "--root" not in base_cmd
@@ -198,7 +198,7 @@ class ModuleExecutor:
# Default fallback # Default fallback
return f"localhost/{module_identifier}:latest" return f"localhost/{module_identifier}:latest"
def _pull_module_image(self, module_identifier: str, registry_url: str = "ghcr.io/fuzzinglabs", tag: str = "latest") -> None: def _pull_module_image(self, module_identifier: str, registry_url: str, tag: str = "latest") -> None:
"""Pull a module image from the container registry. """Pull a module image from the container registry.
:param module_identifier: Name/identifier of the module to pull. :param module_identifier: Name/identifier of the module to pull.
@@ -238,13 +238,13 @@ class ModuleExecutor:
) )
raise SandboxError(message) from exc raise SandboxError(message) from exc
def _ensure_module_image(self, module_identifier: str, registry_url: str = "ghcr.io/fuzzinglabs", tag: str = "latest") -> None: def _ensure_module_image(self, module_identifier: str, registry_url: str = "", tag: str = "latest") -> None:
"""Ensure module image exists, pulling it if necessary. """Ensure module image exists, pulling it if necessary.
:param module_identifier: Name/identifier of the module image. :param module_identifier: Name/identifier of the module image.
:param registry_url: Container registry URL to pull from. :param registry_url: Container registry URL to pull from (empty = local-only mode).
:param tag: Image tag to pull. :param tag: Image tag to pull.
:raises SandboxError: If image check or pull fails. :raises SandboxError: If image not found locally and no registry configured.
""" """
logger = get_logger() logger = get_logger()
@@ -253,6 +253,15 @@ class ModuleExecutor:
logger.debug("module image exists locally", module=module_identifier) logger.debug("module image exists locally", module=module_identifier)
return return
# If no registry configured, we're in local-only mode
if not registry_url:
raise SandboxError(
f"Module image '{module_identifier}' not found locally.\n"
"Build it with: make build-modules\n"
"\n"
"Or configure a registry URL via FUZZFORGE_REGISTRY__URL environment variable."
)
logger.info( logger.info(
"module image not found locally, pulling from registry", "module image not found locally, pulling from registry",
module=module_identifier, module=module_identifier,
@@ -332,6 +341,7 @@ class ModuleExecutor:
try: try:
# Create temporary directory - caller must clean it up after container finishes # Create temporary directory - caller must clean it up after container finishes
from tempfile import mkdtemp from tempfile import mkdtemp
temp_path = Path(mkdtemp(prefix="fuzzforge-input-")) temp_path = Path(mkdtemp(prefix="fuzzforge-input-"))
# Copy assets to temp directory # Copy assets to temp directory
@@ -341,16 +351,19 @@ class ModuleExecutor:
if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"): if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"):
# Extract archive contents # Extract archive contents
import tarfile import tarfile
with tarfile.open(assets_path, "r:gz") as tar: with tarfile.open(assets_path, "r:gz") as tar:
tar.extractall(path=temp_path) tar.extractall(path=temp_path)
logger.debug("extracted tar.gz archive", archive=str(assets_path)) logger.debug("extracted tar.gz archive", archive=str(assets_path))
else: else:
# Single file - copy it # Single file - copy it
import shutil import shutil
shutil.copy2(assets_path, temp_path / assets_path.name) shutil.copy2(assets_path, temp_path / assets_path.name)
else: else:
# Directory - copy all files (including subdirectories) # Directory - copy all files (including subdirectories)
import shutil import shutil
for item in assets_path.iterdir(): for item in assets_path.iterdir():
if item.is_file(): if item.is_file():
shutil.copy2(item, temp_path / item.name) shutil.copy2(item, temp_path / item.name)
@@ -363,19 +376,23 @@ class ModuleExecutor:
if item.name == "input.json": if item.name == "input.json":
continue continue
if item.is_file(): if item.is_file():
resources.append({ resources.append(
"name": item.stem, {
"description": f"Input file: {item.name}", "name": item.stem,
"kind": "unknown", "description": f"Input file: {item.name}",
"path": f"/data/input/{item.name}", "kind": "unknown",
}) "path": f"/data/input/{item.name}",
}
)
elif item.is_dir(): elif item.is_dir():
resources.append({ resources.append(
"name": item.name, {
"description": f"Input directory: {item.name}", "name": item.name,
"kind": "unknown", "description": f"Input directory: {item.name}",
"path": f"/data/input/{item.name}", "kind": "unknown",
}) "path": f"/data/input/{item.name}",
}
)
# Create input.json with settings and resources # Create input.json with settings and resources
input_data = { input_data = {
@@ -461,6 +478,7 @@ class ModuleExecutor:
try: try:
# Create temporary directory for results # Create temporary directory for results
from tempfile import mkdtemp from tempfile import mkdtemp
temp_dir = Path(mkdtemp(prefix="fuzzforge-results-")) temp_dir = Path(mkdtemp(prefix="fuzzforge-results-"))
# Copy entire output directory from container # Copy entire output directory from container
@@ -489,6 +507,7 @@ class ModuleExecutor:
# Clean up temp directory # Clean up temp directory
import shutil import shutil
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path)) logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path))
@@ -571,6 +590,7 @@ class ModuleExecutor:
self.terminate_sandbox(sandbox) self.terminate_sandbox(sandbox)
if input_dir and input_dir.exists(): if input_dir and input_dir.exists():
import shutil import shutil
shutil.rmtree(input_dir, ignore_errors=True) shutil.rmtree(input_dir, ignore_errors=True)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -669,4 +689,5 @@ class ModuleExecutor:
self.terminate_sandbox(container_id) self.terminate_sandbox(container_id)
if input_dir: if input_dir:
import shutil import shutil
shutil.rmtree(input_dir, ignore_errors=True) shutil.rmtree(input_dir, ignore_errors=True)
@@ -60,10 +60,15 @@ class ProjectSettings(BaseModel):
class RegistrySettings(BaseModel): class RegistrySettings(BaseModel):
"""Container registry configuration for module images.""" """Container registry configuration for module images.
#: Registry URL for pulling module images. By default, registry URL is empty (local-only mode). When empty,
url: str = Field(default="ghcr.io/fuzzinglabs") modules must be built locally with `make build-modules`.
Set via FUZZFORGE_REGISTRY__URL environment variable if needed.
"""
#: Registry URL for pulling module images (empty = local-only mode).
url: str = Field(default="")
#: Default tag to use when pulling images. #: Default tag to use when pulling images.
default_tag: str = Field(default="latest") default_tag: str = Field(default="latest")
+20
View File
@@ -0,0 +1,20 @@
line-length = 120
[lint]
select = [ "ALL" ]
ignore = [
"COM812", # conflicts with the formatter
"D100", # ignoring missing docstrings in public modules
"D104", # ignoring missing docstrings in public packages
"D203", # conflicts with 'D211'
"D213", # conflicts with 'D212'
"TD002", # ignoring missing author in 'TODO' statements
"TD003", # ignoring missing issue link in 'TODO' statements
]
[lint.per-file-ignores]
"tests/*" = [
"ANN401", # allowing 'typing.Any' to be used to type function parameters in tests
"PLR2004", # allowing comparisons using unamed numerical constants in tests
"S101", # allowing 'assert' statements in tests
]