diff --git a/server/opensandbox_server/config.py b/server/opensandbox_server/config.py index 0abc012e7..58610b496 100644 --- a/server/opensandbox_server/config.py +++ b/server/opensandbox_server/config.py @@ -430,7 +430,7 @@ class EgressConfig(BaseModel): class RuntimeConfig(BaseModel): """Runtime selection (docker, kubernetes, etc.).""" - type: Literal["docker", "kubernetes"] = Field( + type: Literal["docker", "kubernetes", "podman"] = Field( ..., description="Active sandbox runtime implementation.", ) @@ -555,6 +555,18 @@ class DockerConfig(BaseModel): ) +class PodmanConfig(BaseModel): + """Podman-specific settings.""" + + socket_path: Optional[str] = Field( + default=None, + description=( + "Explicit Podman API socket path. " + "Auto-detected from standard platform locations if omitted." + ), + ) + + class AppConfig(BaseModel): """Root application configuration model.""" @@ -568,6 +580,7 @@ class AppConfig(BaseModel): agent_sandbox: Optional["AgentSandboxRuntimeConfig"] = None ingress: Optional[IngressConfig] = None docker: DockerConfig = Field(default_factory=DockerConfig) + podman: PodmanConfig = Field(default_factory=PodmanConfig) storage: StorageConfig = Field(default_factory=StorageConfig) egress: Optional[EgressConfig] = None secure_runtime: Optional[SecureRuntimeConfig] = Field( @@ -577,15 +590,16 @@ class AppConfig(BaseModel): @model_validator(mode="after") def validate_runtime_blocks(self) -> "AppConfig": - if self.runtime.type == "docker": + if self.runtime.type in ("docker", "podman"): + rt = self.runtime.type if self.kubernetes is not None: - raise ValueError("Kubernetes block must be omitted when runtime.type = 'docker'.") + raise ValueError(f"Kubernetes block must be omitted when runtime.type = '{rt}'.") if self.agent_sandbox is not None: - raise ValueError("agent_sandbox block must be omitted when runtime.type = 'docker'.") + raise ValueError(f"agent_sandbox block must be omitted when runtime.type = '{rt}'.") if self.ingress is not None and self.ingress.mode != INGRESS_MODE_DIRECT: - raise ValueError("ingress.mode must be 'direct' when runtime.type = 'docker'.") + raise ValueError(f"ingress.mode must be 'direct' when runtime.type = '{rt}'.") if self.secure_runtime is not None and self.secure_runtime.type == "firecracker": - raise ValueError( "secure_runtime.type 'firecracker' is only compatible with runtime.type='kubernetes'.") + raise ValueError("secure_runtime.type 'firecracker' is only compatible with runtime.type='kubernetes'.") elif self.runtime.type == "kubernetes": if self.kubernetes is None: self.kubernetes = KubernetesRuntimeConfig() @@ -695,6 +709,7 @@ def get_config_path() -> Path: "INGRESS_MODE_DIRECT", "INGRESS_MODE_GATEWAY", "DockerConfig", + "PodmanConfig", "StorageConfig", "KubernetesRuntimeConfig", "EgressConfig", diff --git a/server/opensandbox_server/main.py b/server/opensandbox_server/main.py index 52decb3f7..ab7951c61 100644 --- a/server/opensandbox_server/main.py +++ b/server/opensandbox_server/main.py @@ -95,12 +95,24 @@ async def lifespan(app: FastAPI): k8s_client = None runtime_type = app_config.runtime.type - if runtime_type == "docker": + secure_rt = getattr(app_config, "secure_runtime", None) + needs_validation = secure_rt is not None and secure_rt.type != "" + + if runtime_type in ("docker", "podman") and needs_validation: import docker - docker_client = docker.from_env() - logger.info("Validating secure runtime for Docker backend") - elif runtime_type == "kubernetes": + if runtime_type == "podman": + from opensandbox_server.services.podman import PodmanSandboxService + + base_url = PodmanSandboxService._resolve_podman_url(app_config) + if base_url: + docker_client = docker.DockerClient(base_url=base_url) + else: + docker_client = docker.from_env() + else: + docker_client = docker.from_env() + logger.info("Validating secure runtime for %s backend", runtime_type) + elif runtime_type == "kubernetes" and needs_validation: from opensandbox_server.services.k8s.client import K8sClient k8s_client = K8sClient(app_config.kubernetes) diff --git a/server/opensandbox_server/services/__init__.py b/server/opensandbox_server/services/__init__.py index 3a8d2f388..d323d5ab7 100644 --- a/server/opensandbox_server/services/__init__.py +++ b/server/opensandbox_server/services/__init__.py @@ -17,6 +17,7 @@ from opensandbox_server.services.docker import DockerSandboxService from opensandbox_server.services.extension_service import ExtensionService, require_extension_service from opensandbox_server.services.k8s.kubernetes_service import KubernetesSandboxService +from opensandbox_server.services.podman import PodmanSandboxService from opensandbox_server.services.factory import create_sandbox_service from opensandbox_server.services.sandbox_service import SandboxService @@ -26,5 +27,6 @@ "require_extension_service", "DockerSandboxService", "KubernetesSandboxService", + "PodmanSandboxService", "create_sandbox_service", ] diff --git a/server/opensandbox_server/services/docker.py b/server/opensandbox_server/services/docker.py index 89e3c980b..dcaac1ba3 100644 --- a/server/opensandbox_server/services/docker.py +++ b/server/opensandbox_server/services/docker.py @@ -136,6 +136,11 @@ class DockerSandboxService(DockerDiagnosticsMixin, OSSFSMixin, SandboxService, E This class implements sandbox lifecycle operations using Docker containers. """ + @classmethod + def _supported_runtime_types(cls) -> tuple[str, ...]: + """Runtime types accepted by this service class (overridable by subclasses).""" + return ("docker",) + def __init__(self, config: Optional[AppConfig] = None): """ Initialize Docker sandbox service. @@ -151,49 +156,25 @@ def __init__(self, config: Optional[AppConfig] = None): """ self.app_config = config or get_config() runtime_config = self.app_config.runtime - if runtime_config.type != "docker": - raise ValueError("DockerSandboxService requires runtime.type = 'docker'.") + if runtime_config.type not in self._supported_runtime_types(): + raise ValueError( + f"{type(self).__name__} requires runtime.type in {self._supported_runtime_types()}." + ) self.execd_image = runtime_config.execd_image self.network_mode = (self.app_config.docker.network_mode or HOST_NETWORK_MODE).lower() self._execd_archive_cache: Optional[bytes] = None self._api_timeout = self._resolve_api_timeout() try: - # Initialize Docker service from environment variables - client_kwargs = {} - try: - signature = inspect.signature(docker.from_env) - if "timeout" in signature.parameters: - client_kwargs["timeout"] = self._api_timeout - except (ValueError, TypeError): - logger.debug( - "Unable to introspect docker.from_env signature; using default parameters." - ) - self.docker_client = docker.from_env(**client_kwargs) - if not client_kwargs: - try: - self.docker_client.api.timeout = self._api_timeout - except AttributeError: - logger.debug("Docker client API does not expose timeout attribute.") - logger.info("Docker service initialized from environment") + self.docker_client = self._create_docker_client() + logger.info("%s initialized from environment", type(self).__name__) except Exception as e: # noqa: BLE001 - # Common failure mode on macOS/dev machines: Docker daemon not running or socket path wrong. - hint = "" - msg = str(e) - if isinstance(e, FileNotFoundError) or "No such file or directory" in msg: - docker_host = os.environ.get("DOCKER_HOST", "") - hint = ( - " Docker daemon seems unavailable (unix socket not found). " - "Make sure Docker Desktop (or Colima/Rancher Desktop) is running. " - "If you use Colima on macOS, you may need to set " - "DOCKER_HOST=unix://${HOME}/.colima/default/docker.sock before starting the server. " - f"(current DOCKER_HOST='{docker_host}')" - ) + hint = self._connection_error_hint(e) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ "code": SandboxErrorCodes.DOCKER_INITIALIZATION_ERROR, - "message": f"Failed to initialize Docker service: {str(e)}.{hint}", + "message": f"Failed to initialize {type(self).__name__}: {str(e)}.{hint}", }, ) self._expiration_lock = Lock() @@ -218,6 +199,39 @@ def _resolve_api_timeout(self) -> int: return cfg return 180 + def _connection_error_hint(self, error: Exception) -> str: + """Return a user-friendly hint when the container daemon is unreachable.""" + msg = str(error) + if isinstance(error, FileNotFoundError) or "No such file or directory" in msg: + docker_host = os.environ.get("DOCKER_HOST", "") + return ( + " Docker daemon seems unavailable (unix socket not found). " + "Make sure Docker Desktop (or Colima/Rancher Desktop) is running. " + "If you use Colima on macOS, you may need to set " + "DOCKER_HOST=unix://${HOME}/.colima/default/docker.sock before starting the server. " + f"(current DOCKER_HOST='{docker_host}')" + ) + return "" + + def _create_docker_client(self): + """Create and return a Docker SDK client (overridable by subclasses).""" + client_kwargs: dict = {} + try: + signature = inspect.signature(docker.from_env) + if "timeout" in signature.parameters: + client_kwargs["timeout"] = self._api_timeout + except (ValueError, TypeError): + logger.debug( + "Unable to introspect docker.from_env signature; using default parameters." + ) + client = docker.from_env(**client_kwargs) + if not client_kwargs: + try: + client.api.timeout = self._api_timeout + except AttributeError: + logger.debug("Docker client API does not expose timeout attribute.") + return client + @contextmanager def _docker_operation(self, action: str, sandbox_id: Optional[str] = None): """Context manager to log duration for Docker API calls.""" diff --git a/server/opensandbox_server/services/factory.py b/server/opensandbox_server/services/factory.py index 10b9d3d92..73ccd68d5 100644 --- a/server/opensandbox_server/services/factory.py +++ b/server/opensandbox_server/services/factory.py @@ -25,6 +25,7 @@ from opensandbox_server.config import AppConfig, get_config from opensandbox_server.services.docker import DockerSandboxService from opensandbox_server.services.k8s import KubernetesSandboxService +from opensandbox_server.services.podman import PodmanSandboxService from opensandbox_server.services.sandbox_service import SandboxService logger = logging.getLogger(__name__) @@ -57,6 +58,7 @@ def create_sandbox_service( implementations: dict[str, type[SandboxService]] = { "docker": DockerSandboxService, "kubernetes": KubernetesSandboxService, + "podman": PodmanSandboxService, # Future implementations can be added here: # "containerd": ContainerdSandboxService, } diff --git a/server/opensandbox_server/services/podman.py b/server/opensandbox_server/services/podman.py new file mode 100644 index 000000000..286bef77b --- /dev/null +++ b/server/opensandbox_server/services/podman.py @@ -0,0 +1,172 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Podman-based implementation of SandboxService. + +Inherits from DockerSandboxService and communicates with Podman through its +Docker-compatible API socket. Only the handful of operations where Podman's +compat layer diverges from Docker are overridden here; everything else +(container lifecycle, image management, volume mounts, port mapping, egress +sidecar, bootstrap injection, …) is reused as-is from the parent class. +""" + +import logging +import os +import sys +from typing import Dict, Optional + +import docker as docker_mod +from urllib3.util.retry import Retry + +from opensandbox_server.config import AppConfig, get_config +from opensandbox_server.services.docker import DockerSandboxService + +logger = logging.getLogger(__name__) + + +class PodmanSandboxService(DockerSandboxService): + """Sandbox service backed by Podman via the Docker-compatible API.""" + + @classmethod + def _supported_runtime_types(cls) -> tuple[str, ...]: + return ("podman",) + + + def __init__(self, config: Optional[AppConfig] = None): + app_config = config or get_config() + self._podman_base_url = self._resolve_podman_url(app_config) + super().__init__(config=app_config) + self._configure_retry_adapter() + + def _create_docker_client(self): + """Create a Docker SDK client connected to the Podman compat socket.""" + kwargs: dict = {"timeout": self._api_timeout} + if self._podman_base_url: + kwargs["base_url"] = self._podman_base_url + logger.info("Connecting to Podman at %s", self._podman_base_url) + return docker_mod.DockerClient(**kwargs) + # Fall back to environment / default detection. + return super()._create_docker_client() + + def _configure_retry_adapter(self) -> None: + """Patch the existing transport adapter to retry on idle disconnects. + + Podman (especially on Windows named pipes) may close idle connections + earlier than Docker. The Docker SDK reuses HTTP connections by default, + so a subsequent API call on a stale connection hits + ``RemoteDisconnected``. Rather than replacing the transport adapter + (which would break named-pipe support), we patch ``max_retries`` on + the adapter that the Docker SDK already installed. + """ + try: + retry = Retry(total=3, connect=3, read=1, backoff_factor=0.1) + adapter = self.docker_client.api.get_adapter("http+docker://") + adapter.max_retries = retry + logger.debug("Retry policy patched on existing Docker SDK adapter.") + except Exception: # noqa: BLE001 + logger.debug("Could not configure retry policy for Podman client.") + + @staticmethod + def _resolve_podman_url(config: AppConfig) -> Optional[str]: + """Return the Podman API URL without mutating ``os.environ``.""" + if os.environ.get("DOCKER_HOST"): + return None + + socket_path = config.podman.socket_path + if socket_path: + return socket_path if "://" in socket_path else f"unix://{socket_path}" + + return PodmanSandboxService._detect_podman_socket() + + @staticmethod + def _detect_podman_socket() -> Optional[str]: + """Return the first reachable Podman API socket for the current platform.""" + if sys.platform == "win32": + return _check_windows_pipe("podman-machine-default") + + if sys.platform == "darwin": + home = os.environ.get("HOME", "") + candidates = [ + f"{home}/.local/share/containers/podman/machine/podman.sock", + f"{home}/.local/share/containers/podman/machine/qemu/podman.sock", + ] + else: # linux + xdg = os.environ.get( + "XDG_RUNTIME_DIR", + f"/run/user/{os.getuid()}", + ) + candidates = [ + f"{xdg}/podman/podman.sock", # rootless + "/run/podman/podman.sock", # rootful + ] + + for path in candidates: + if os.path.exists(path): + return f"unix://{path}" + + return None + + def _connection_error_hint(self, error: Exception) -> str: + """Return a Podman-specific hint when the API socket is unreachable.""" + msg = str(error) + if isinstance(error, FileNotFoundError) or "No such file or directory" in msg: + docker_host = os.environ.get("DOCKER_HOST", "") + base = self._podman_base_url or docker_host + return ( + " Podman API socket seems unavailable. " + "Make sure Podman is installed and the socket is active " + "(run 'systemctl --user start podman.socket' on Linux, " + "or 'podman machine start' on macOS/Windows). " + f"(current target='{base}')" + ) + return "" + + def _update_container_labels(self, container, labels: Dict[str, str]) -> None: + """Skip container label updates — Podman does not support this operation. + + Expiration is already tracked in-memory via ``_sandbox_expirations`` by + ``_schedule_expiration()``. The only consequence of skipping the label + write is that a server restart after ``renew_expiration`` will fall back + to the original expiration timestamp stored in the container label at + creation time. This is an acceptable degradation — the sandbox may + expire earlier than the renewed time, matching the behaviour the parent + class already tolerates when the label update fails (see the + ``except (DockerException, TypeError)`` guard in ``renew_expiration``). + """ + logger.debug( + "Skipping container label update on Podman (not supported): %s", + container.id[:12] if hasattr(container, "id") else "unknown", + ) + + +def _check_windows_pipe(pipe_name: str) -> Optional[str]: + """Verify that a Windows named pipe is reachable (non-blocking).""" + pipe_path = f"\\\\.\\pipe\\{pipe_name}" + try: + import ctypes + + # WaitNamedPipeW returns non-zero if the pipe is available within + # the timeout (milliseconds). Using a short timeout avoids + # blocking startup if the pipe exists but the server is busy. + _TIMEOUT_MS = 1000 + result = ctypes.windll.kernel32.WaitNamedPipeW(pipe_path, _TIMEOUT_MS) # type: ignore[union-attr] + if not result: + return None + except (AttributeError, OSError): + # ctypes.windll is only available on Windows; AttributeError + # covers non-Windows platforms where this is called by mistake. + return None + # The Docker SDK expects forward slashes in the npipe:// URL. + return f"npipe:////./pipe/{pipe_name}" diff --git a/server/opensandbox_server/services/runtime_resolver.py b/server/opensandbox_server/services/runtime_resolver.py index 240ef6b3a..b109fbce0 100644 --- a/server/opensandbox_server/services/runtime_resolver.py +++ b/server/opensandbox_server/services/runtime_resolver.py @@ -155,7 +155,7 @@ async def validate_secure_runtime_on_startup( logger.info("Secure runtime is not configured.") return - if config.runtime.type == "docker": + if config.runtime.type in ("docker", "podman"): await _validate_docker_runtime(resolver, docker_client) elif config.runtime.type == "kubernetes": await _validate_k8s_runtime_class(resolver, k8s_client, config) @@ -170,18 +170,18 @@ async def _validate_docker_runtime( resolver: SecureRuntimeResolver, docker_client: Optional["DockerClient"], ) -> None: - """Validate that the Docker OCI runtime exists.""" + """Validate that the configured OCI runtime exists.""" runtime_name = resolver.get_docker_runtime() if not runtime_name: - logger.info("No Docker runtime configured for secure containers.") + logger.info("No OCI runtime configured for secure containers.") return - logger.info("Validating Docker OCI runtime: %s", runtime_name) + logger.info("Validating OCI runtime: %s", runtime_name) if docker_client is None: logger.warning( - "Docker client not available; skipping runtime validation. " + "Container client not available; skipping runtime validation. " "Runtime '%s' will be used but not validated.", runtime_name, ) @@ -196,18 +196,18 @@ async def _validate_docker_runtime( if runtime_name not in runtimes: available = ", ".join(runtimes.keys()) if runtimes else "none" raise ValueError( - f"Configured Docker runtime '{runtime_name}' is not available. " + f"Configured OCI runtime '{runtime_name}' is not available. " f"Available runtimes: {available}. " f"Please install and configure the runtime before starting the server." ) logger.info( - "Docker OCI runtime '%s' is available: %s", + "OCI runtime '%s' is available: %s", runtime_name, runtimes.get(runtime_name, {}), ) except Exception as exc: - logger.error("Failed to validate Docker runtime: %s", exc) + logger.error("Failed to validate OCI runtime: %s", exc) raise diff --git a/server/tests/test_podman_service.py b/server/tests/test_podman_service.py new file mode 100644 index 000000000..52f047c46 --- /dev/null +++ b/server/tests/test_podman_service.py @@ -0,0 +1,330 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for PodmanSandboxService.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from opensandbox_server.config import ( + AppConfig, + IngressConfig, + PodmanConfig, + RuntimeConfig, + ServerConfig, +) +from opensandbox_server.services.podman import PodmanSandboxService, _check_windows_pipe + + +def _podman_config(**overrides) -> AppConfig: + defaults = dict( + server=ServerConfig(), + runtime=RuntimeConfig(type="podman", execd_image="ghcr.io/opensandbox/platform:latest"), + ingress=IngressConfig(mode="direct"), + ) + defaults.update(overrides) + return AppConfig(**defaults) + + +def _mock_docker(): + """Return a MagicMock that behaves like ``docker.from_env()``.""" + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + mock_client.api.get_adapter.return_value = MagicMock() + return mock_client + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_podman_service_init_succeeds(mock_podman_docker, mock_docker): + """PodmanSandboxService can be constructed with runtime.type='podman'.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + service = PodmanSandboxService(config=_podman_config()) + assert service is not None + assert service.app_config.runtime.type == "podman" + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_podman_service_rejects_docker_type(mock_podman_docker, mock_docker): + """PodmanSandboxService must reject runtime.type='docker'.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + config = AppConfig( + server=ServerConfig(), + runtime=RuntimeConfig(type="docker", execd_image="test:latest"), + ingress=IngressConfig(mode="direct"), + ) + with pytest.raises(ValueError, match="PodmanSandboxService"): + PodmanSandboxService(config=config) + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_update_container_labels_is_noop(mock_podman_docker, mock_docker): + """_update_container_labels must NOT call container.update on Podman.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + service = PodmanSandboxService(config=_podman_config()) + + mock_container = MagicMock() + mock_container.id = "abc123def456" + service._update_container_labels(mock_container, {"foo": "bar"}) + + mock_container.update.assert_not_called() + mock_container.reload.assert_not_called() + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_connection_error_hint_mentions_podman(mock_podman_docker, mock_docker): + """Error hint should reference Podman, not Docker Desktop.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + service = PodmanSandboxService(config=_podman_config()) + hint = service._connection_error_hint(FileNotFoundError("/run/podman/podman.sock")) + + assert "Podman" in hint + assert "Docker Desktop" not in hint + assert "podman.socket" in hint or "podman machine start" in hint + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_connection_error_hint_empty_for_generic_error(mock_podman_docker, mock_docker): + """Non-socket errors should return an empty hint.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + service = PodmanSandboxService(config=_podman_config()) + hint = service._connection_error_hint(RuntimeError("something else")) + assert hint == "" + + +@patch("sys.platform", "linux") +@patch("os.getuid", return_value=1000, create=True) +@patch("os.path.exists", return_value=True) +def test_socket_detection_linux(mock_exists, mock_getuid): + """On Linux, detect the rootless Podman socket.""" + with patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/run/user/1000"}, clear=False): + result = PodmanSandboxService._detect_podman_socket() + assert result is not None + assert "podman" in result + assert "/run/user/1000" in result + assert result.startswith("unix://") + + +@patch("sys.platform", "win32") +@patch("opensandbox_server.services.podman._check_windows_pipe") +def test_socket_detection_windows(mock_check_pipe): + """On Windows, delegate to _check_windows_pipe.""" + mock_check_pipe.return_value = "npipe:////./pipe/podman-machine-default" + result = PodmanSandboxService._detect_podman_socket() + assert result is not None + assert "npipe://" in result + assert "podman-machine-default" in result + mock_check_pipe.assert_called_once_with("podman-machine-default") + + +@patch("sys.platform", "win32") +@patch("opensandbox_server.services.podman._check_windows_pipe", return_value=None) +def test_socket_detection_windows_no_podman(mock_check_pipe): + """On Windows, return None when Podman pipe is not reachable.""" + result = PodmanSandboxService._detect_podman_socket() + assert result is None + + +@patch("sys.platform", "darwin") +@patch("os.path.exists", return_value=True) +def test_socket_detection_macos(mock_exists): + """On macOS, detect the Podman Machine socket.""" + with patch.dict(os.environ, {"HOME": "/Users/testuser"}, clear=False): + result = PodmanSandboxService._detect_podman_socket() + assert result is not None + assert "podman" in result + assert "/Users/testuser" in result + assert result.startswith("unix://") + + +@patch("sys.platform", "linux") +@patch("os.getuid", return_value=1000, create=True) +@patch("os.path.exists", return_value=False) +def test_socket_detection_returns_none_when_no_socket(mock_exists, mock_getuid): + """Return None when no Podman socket exists on disk.""" + with patch.dict(os.environ, {"XDG_RUNTIME_DIR": "/run/user/1000"}, clear=False): + result = PodmanSandboxService._detect_podman_socket() + assert result is None + + +def test_check_windows_pipe_found(): + """Returns pipe URL with forward slashes when WaitNamedPipeW succeeds.""" + mock_kernel32 = MagicMock() + mock_kernel32.WaitNamedPipeW.return_value = 1 # non-zero = success + mock_windll = MagicMock(kernel32=mock_kernel32) + mock_ctypes = MagicMock(windll=mock_windll) + + with patch.dict("sys.modules", {"ctypes": mock_ctypes}): + result = _check_windows_pipe("podman-machine-default") + + assert result == "npipe:////./pipe/podman-machine-default" + mock_kernel32.WaitNamedPipeW.assert_called_once_with( + "\\\\.\\pipe\\podman-machine-default", 1000 + ) + + +def test_check_windows_pipe_not_found(): + """Returns None when WaitNamedPipeW returns 0 (pipe not available).""" + mock_kernel32 = MagicMock() + mock_kernel32.WaitNamedPipeW.return_value = 0 # 0 = failure + mock_windll = MagicMock(kernel32=mock_kernel32) + mock_ctypes = MagicMock(windll=mock_windll) + + with patch.dict("sys.modules", {"ctypes": mock_ctypes}): + result = _check_windows_pipe("nonexistent-pipe") + + assert result is None + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_resolve_does_not_mutate_environ(mock_podman_docker, mock_docker): + """_resolve_podman_url must not set os.environ['DOCKER_HOST'].""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("DOCKER_HOST", None) + config = _podman_config(podman=PodmanConfig(socket_path="/my/podman.sock")) + service = PodmanSandboxService(config=config) + assert "DOCKER_HOST" not in os.environ + assert service._podman_base_url == "unix:///my/podman.sock" + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_create_docker_client_uses_base_url(mock_podman_docker, mock_docker): + """_create_docker_client should pass base_url to DockerClient.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + config = _podman_config(podman=PodmanConfig(socket_path="/my/podman.sock")) + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("DOCKER_HOST", None) + PodmanSandboxService(config=config) + + mock_podman_docker.DockerClient.assert_called_once() + call_kwargs = mock_podman_docker.DockerClient.call_args + assert call_kwargs.kwargs.get("base_url") == "unix:///my/podman.sock" + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_respects_existing_docker_host(mock_podman_docker, mock_docker): + """When DOCKER_HOST is set, fall back to parent's from_env().""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + original = "unix:///custom/podman.sock" + with patch.dict(os.environ, {"DOCKER_HOST": original}, clear=False): + service = PodmanSandboxService(config=_podman_config()) + assert service._podman_base_url is None + assert os.environ["DOCKER_HOST"] == original + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_retry_adapter_patches_existing_adapter(mock_podman_docker, mock_docker): + """_configure_retry_adapter should patch max_retries on the SDK adapter.""" + mock_client = _mock_docker() + mock_adapter = MagicMock() + mock_client.api.get_adapter.return_value = mock_adapter + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + PodmanSandboxService(config=_podman_config()) + + mock_client.api.get_adapter.assert_called_with("http+docker://") + assert mock_adapter.max_retries is not None + assert mock_adapter.max_retries.total == 3 + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_retry_adapter_handles_missing_adapter(mock_podman_docker, mock_docker): + """_configure_retry_adapter must not raise if get_adapter fails.""" + mock_client = _mock_docker() + mock_client.api.get_adapter.side_effect = Exception("no adapter") + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + # Should not raise + service = PodmanSandboxService(config=_podman_config()) + assert service is not None + + +@patch("opensandbox_server.services.docker.docker") +@patch("opensandbox_server.services.podman.docker_mod") +def test_factory_creates_podman_service(mock_podman_docker, mock_docker): + """create_sandbox_service('podman') should return PodmanSandboxService.""" + mock_client = _mock_docker() + mock_podman_docker.DockerClient.return_value = mock_client + mock_docker.from_env.return_value = mock_client + + from opensandbox_server.services.factory import create_sandbox_service + + service = create_sandbox_service(config=_podman_config()) + assert isinstance(service, PodmanSandboxService) + + +def test_podman_runtime_type_accepted(): + """RuntimeConfig accepts 'podman' as a valid type.""" + cfg = RuntimeConfig(type="podman", execd_image="test:latest") + assert cfg.type == "podman" + + +def test_podman_config_rejects_kubernetes_block(): + """Podman runtime must reject kubernetes config block.""" + from pydantic import ValidationError + from opensandbox_server.config import KubernetesRuntimeConfig + + with pytest.raises(ValidationError): + AppConfig( + server=ServerConfig(), + runtime=RuntimeConfig(type="podman", execd_image="test:latest"), + kubernetes=KubernetesRuntimeConfig(), + ) + + +def test_podman_config_socket_path(): + """PodmanConfig should accept socket_path.""" + cfg = PodmanConfig(socket_path="/run/podman/podman.sock") + assert cfg.socket_path == "/run/podman/podman.sock" + + cfg_default = PodmanConfig() + assert cfg_default.socket_path is None