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 committed 2026-01-31 10:27:03 +01:00
1 parent e72c5fb201
commit ac6e8a8a34
6 files changed
+111 -35

No files matched your search

@@ -96,7 +96,7 @@ class DockerCLI(AbstractFuzzForgeSandboxEngine):
reference = f"{repo}:{tag}"
if filter_prefix and not reference.startswith(filter_prefix):
if filter_prefix and filter_prefix not in reference:
continue
images.append(
@@ -39,6 +39,7 @@ def _is_running_under_snap() -> bool:
Note: Snap only exists on Linux, so this also handles macOS implicitly.
"""
import os # noqa: PLC0415
return os.getenv("SNAP") is not None
@@ -69,15 +70,23 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
:param runroot: Path to container runtime state.
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)
# 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
)
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
@@ -98,8 +107,10 @@ class PodmanCLI(AbstractFuzzForgeSandboxEngine):
if self.__use_custom_storage and self.__graphroot and self.__runroot:
return [
"podman",
"--root", str(self.__graphroot),
"--runroot", str(self.__runroot),
"--root",
str(self.__graphroot),
"--runroot",
str(self.__runroot),
]
return ["podman"]
@@ -2,28 +2,37 @@
import os
import shutil
import sys
import uuid
from pathlib import Path
from unittest import mock
import pytest
from fuzzforge_common.exceptions import FuzzForgeError
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
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.
Mocks Linux platform since Podman is Linux-only.
"""
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"}):
# Simulate Snap environment for testing on Linux
with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
yield engine
@@ -48,6 +57,15 @@ def test_snap_detection_when_snap_not_set() -> None:
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:
"""Test that PodmanCLI creates storage directories when under Snap."""
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 runroot.exists()
with mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
engine = PodmanCLI(graphroot=graphroot, runroot=runroot)
with _mock_linux_platform(), mock.patch.dict(os.environ, {"SNAP": "/snap/code/123"}):
PodmanCLI(graphroot=graphroot, runroot=runroot)
assert graphroot.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")
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)
base_cmd = engine._base_cmd()
@@ -93,7 +111,7 @@ def test_podman_cli_base_cmd_without_snap() -> None:
env = os.environ.copy()
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)
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:
"""Test PodmanCLI without custom storage paths."""
engine = PodmanCLI() # No paths provided
base_cmd = engine._base_cmd()
with _mock_linux_platform():
engine = PodmanCLI() # No paths provided
base_cmd = engine._base_cmd()
assert base_cmd == ["podman"]
assert "--root" not in base_cmd
@@ -198,7 +198,7 @@ class ModuleExecutor:
# Default fallback
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.
:param module_identifier: Name/identifier of the module to pull.
@@ -238,13 +238,13 @@ class ModuleExecutor:
)
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.
: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.
:raises SandboxError: If image check or pull fails.
:raises SandboxError: If image not found locally and no registry configured.
"""
logger = get_logger()
@@ -253,6 +253,15 @@ class ModuleExecutor:
logger.debug("module image exists locally", module=module_identifier)
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(
"module image not found locally, pulling from registry",
module=module_identifier,
@@ -332,6 +341,7 @@ class ModuleExecutor:
try:
# Create temporary directory - caller must clean it up after container finishes
from tempfile import mkdtemp
temp_path = Path(mkdtemp(prefix="fuzzforge-input-"))
# Copy assets to temp directory
@@ -341,16 +351,19 @@ class ModuleExecutor:
if assets_path.suffix == ".gz" or assets_path.name.endswith(".tar.gz"):
# Extract archive contents
import tarfile
with tarfile.open(assets_path, "r:gz") as tar:
tar.extractall(path=temp_path)
logger.debug("extracted tar.gz archive", archive=str(assets_path))
else:
# Single file - copy it
import shutil
shutil.copy2(assets_path, temp_path / assets_path.name)
else:
# Directory - copy all files (including subdirectories)
import shutil
for item in assets_path.iterdir():
if item.is_file():
shutil.copy2(item, temp_path / item.name)
@@ -363,19 +376,23 @@ class ModuleExecutor:
if item.name == "input.json":
continue
if item.is_file():
resources.append({
"name": item.stem,
"description": f"Input file: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
})
resources.append(
{
"name": item.stem,
"description": f"Input file: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
}
)
elif item.is_dir():
resources.append({
"name": item.name,
"description": f"Input directory: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
})
resources.append(
{
"name": item.name,
"description": f"Input directory: {item.name}",
"kind": "unknown",
"path": f"/data/input/{item.name}",
}
)
# Create input.json with settings and resources
input_data = {
@@ -461,6 +478,7 @@ class ModuleExecutor:
try:
# Create temporary directory for results
from tempfile import mkdtemp
temp_dir = Path(mkdtemp(prefix="fuzzforge-results-"))
# Copy entire output directory from container
@@ -489,6 +507,7 @@ class ModuleExecutor:
# Clean up temp directory
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info("results pulled successfully", sandbox=sandbox, archive=str(archive_path))
@@ -571,6 +590,7 @@ class ModuleExecutor:
self.terminate_sandbox(sandbox)
if input_dir and input_dir.exists():
import shutil
shutil.rmtree(input_dir, ignore_errors=True)
# -------------------------------------------------------------------------
@@ -669,4 +689,5 @@ class ModuleExecutor:
self.terminate_sandbox(container_id)
if input_dir:
import shutil
shutil.rmtree(input_dir, ignore_errors=True)
@@ -60,10 +60,15 @@ class ProjectSettings(BaseModel):
class RegistrySettings(BaseModel):
"""Container registry configuration for module images."""
"""Container registry configuration for module images.
#: Registry URL for pulling module images.
url: str = Field(default="ghcr.io/fuzzinglabs")
By default, registry URL is empty (local-only mode). When empty,
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: 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
]