diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index b89a80409148..969453aa06ba 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -614,31 +614,38 @@ def run_doctor(args): check_warn("ripgrep (rg) not found", "(file search uses grep fallback)") check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}") - # Docker (optional) + # Container runtime: Docker or Podman (optional) terminal_env = os.getenv("TERMINAL_ENV", "local") + try: + from tools.environments.docker import find_docker, runtime_name + container_exe = find_docker() + _rt_name = runtime_name().lower() # "docker" or "podman" + except ImportError: + container_exe = shutil.which("docker") or shutil.which("podman") + _rt_name = "podman" if (container_exe and "podman" in container_exe) else "docker" if terminal_env == "docker": - if shutil.which("docker"): - # Check if docker daemon is running + if container_exe: + # Check if daemon is running try: - result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) + result = subprocess.run([container_exe, "info"], capture_output=True, timeout=10) except subprocess.TimeoutExpired: result = None if result is not None and result.returncode == 0: - check_ok("docker", "(daemon running)") + check_ok(_rt_name, "(daemon running)") else: - check_fail("docker daemon not running") - issues.append("Start Docker daemon") + check_fail(f"{_rt_name} daemon not running") + issues.append(f"Start {_rt_name} daemon") else: - check_fail("docker not found", "(required for TERMINAL_ENV=docker)") - issues.append("Install Docker or change TERMINAL_ENV") + check_fail("docker/podman not found", "(required for TERMINAL_ENV=docker)") + issues.append("Install Docker or Podman, or change TERMINAL_ENV") else: - if shutil.which("docker"): - check_ok("docker", "(optional)") + if container_exe: + check_ok(_rt_name, "(optional)") else: if _is_termux(): - check_info("Docker backend is not available inside Termux (expected on Android)") + check_info("Container backend is not available inside Termux (expected on Android)") else: - check_warn("docker not found", "(optional)") + check_warn("docker/podman not found", "(optional)") # SSH (if using ssh backend) if terminal_env == "ssh": diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 9044871dc3bf..d96d426ec098 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1134,15 +1134,22 @@ def setup_terminal_backend(config: dict): print_success("Sudo password saved") elif selected_backend == "docker": - print_success("Terminal backend: Docker") + print_success("Terminal backend: Docker/Podman") - # Check if Docker is available - docker_bin = shutil.which("docker") + # Check if a container runtime is available + try: + from tools.environments.docker import find_docker, runtime_name + docker_bin = find_docker() + _rt = runtime_name() + except ImportError: + docker_bin = shutil.which("docker") or shutil.which("podman") + _rt = "Podman" if (docker_bin and "podman" in docker_bin) else "Docker" if not docker_bin: - print_warning("Docker not found in PATH!") + print_warning("Docker/Podman not found in PATH!") print_info("Install Docker: https://docs.docker.com/get-docker/") + print_info(" or Podman: https://podman.io/getting-started/installation") else: - print_info(f"Docker found: {docker_bin}") + print_info(f"{_rt} found: {docker_bin}") # Docker image current_image = config.get("terminal", {}).get( diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index dd15336f6075..19be7d45b00d 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -237,15 +237,23 @@ def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkey real_which = doctor_mod.shutil.which def fake_which(cmd): - if cmd in {"docker", "node", "npm"}: + if cmd in {"docker", "podman", "node", "npm"}: return None return real_which(cmd) monkeypatch.setattr(doctor_mod.shutil, "which", fake_which) + # Also ensure find_docker() doesn't find a real podman binary + try: + from tools.environments import docker as _docker_mod + monkeypatch.setattr(_docker_mod, "_docker_executable", None) + monkeypatch.setattr(_docker_mod, "_runtime_is_podman", None) + monkeypatch.setattr(_docker_mod.shutil, "which", fake_which) + except ImportError: + pass out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="") - assert "Docker backend is not available inside Termux" in out + assert "Container backend is not available inside Termux" in out assert "Node.js not found (browser tools are optional in the tested Termux path)" in out assert "Install Node.js on Termux with: pkg install nodejs" in out assert "Termux browser setup:" in out diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index e19229a795e8..5ca31b901755 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -9,6 +9,14 @@ from tools.environments import docker as docker_env +@pytest.fixture(autouse=True) +def _reset_runtime_cache(): + """Clear the Podman detection cache between tests.""" + docker_env._runtime_is_podman = None + yield + docker_env._runtime_is_podman = None + + def _mock_subprocess_run(monkeypatch): """Mock subprocess.run to intercept docker run -d and docker version calls. @@ -62,10 +70,9 @@ def test_ensure_docker_available_logs_and_raises_when_not_found(monkeypatch, cap with pytest.raises(RuntimeError) as excinfo: _make_dummy_env() - assert "Docker executable not found in PATH or known install locations" in str(excinfo.value) + assert "No container runtime found" in str(excinfo.value) assert any( - "no docker executable was found in PATH or known install locations" - in record.getMessage() + "no docker or podman executable" in record.getMessage() for record in caplog.records ) diff --git a/tests/tools/test_docker_find.py b/tests/tools/test_docker_find.py index 0cf9c32087ca..9491a1f5bae7 100644 --- a/tests/tools/test_docker_find.py +++ b/tests/tools/test_docker_find.py @@ -12,8 +12,10 @@ def _reset_cache(): """Clear the module-level docker executable cache between tests.""" docker_mod._docker_executable = None + docker_mod._runtime_is_podman = None yield docker_mod._docker_executable = None + docker_mod._runtime_is_podman = None class TestFindDocker: @@ -102,3 +104,68 @@ def which_side_effect(name): with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect): result = docker_mod.find_docker() assert result == "/usr/bin/docker" + + +class TestIsPodman: + """Tests for is_podman() and runtime_name() helpers.""" + + def test_docker_is_not_podman(self): + with patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + docker_mod.find_docker() + assert docker_mod.is_podman() is False + assert docker_mod.runtime_name() == "Docker" + + def test_podman_is_podman(self): + def which_side_effect(name): + if name == "podman": + return "/usr/bin/podman" + return None + + with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect), \ + patch("tools.environments.docker._DOCKER_SEARCH_PATHS", []): + docker_mod.find_docker() + assert docker_mod.is_podman() is True + assert docker_mod.runtime_name() == "Podman" + + def test_env_override_podman(self, tmp_path): + fake = tmp_path / "podman" + fake.write_text("#!/bin/sh\n") + fake.chmod(0o755) + + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": str(fake)}): + docker_mod.find_docker() + assert docker_mod.is_podman() is True + + def test_no_runtime_is_not_podman(self): + with patch("tools.environments.docker.shutil.which", return_value=None), \ + patch("tools.environments.docker._DOCKER_SEARCH_PATHS", []): + docker_mod.find_docker() + assert docker_mod.is_podman() is False + + def test_is_podman_cached(self): + """Second call uses cache.""" + with patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + docker_mod.find_docker() + first = docker_mod.is_podman() + # Patch find_docker to return podman — should still return cached False + docker_mod._docker_executable = "/usr/bin/podman" + second = docker_mod.is_podman() + assert first is False + assert second is False # cached + + +class TestStorageOptPodman: + """_storage_opt_supported() should return False for Podman.""" + + def test_podman_skips_storage_opt(self): + def which_side_effect(name): + if name == "podman": + return "/usr/bin/podman" + return None + + with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect), \ + patch("tools.environments.docker._DOCKER_SEARCH_PATHS", []): + docker_mod.find_docker() + # Reset storage opt cache + docker_mod._storage_opt_ok = None + assert docker_mod.DockerEnvironment._storage_opt_supported() is False diff --git a/tools/environments/docker.py b/tools/environments/docker.py index d2ea5c964cf6..4bc355948524 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -30,6 +30,7 @@ ] _docker_executable: Optional[str] = None # resolved once, cached +_runtime_is_podman: Optional[bool] = None # resolved once, cached _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -143,6 +144,21 @@ def find_docker() -> Optional[str]: return None +def is_podman() -> bool: + """Return True if the resolved container runtime is Podman, not Docker.""" + global _runtime_is_podman + if _runtime_is_podman is not None: + return _runtime_is_podman + exe = find_docker() + _runtime_is_podman = exe is not None and "podman" in os.path.basename(exe) + return _runtime_is_podman + + +def runtime_name() -> str: + """Return a user-facing name for the resolved container runtime.""" + return "Podman" if is_podman() else "Docker" + + # Security flags applied to every container. # The container itself is the security boundary (isolated from host). # We drop all capabilities then add back the minimum needed: @@ -175,13 +191,12 @@ def _ensure_docker_available() -> None: docker_exe = find_docker() if not docker_exe: logger.error( - "Docker backend selected but no docker executable was found in PATH " - "or known install locations. Install Docker Desktop and ensure the " - "CLI is available." + "Container backend selected but no docker or podman executable was " + "found in PATH or known install locations." ) raise RuntimeError( - "Docker executable not found in PATH or known install locations. " - "Install Docker and ensure the 'docker' command is available." + "No container runtime found (docker or podman). " + "Install Docker or Podman and ensure the CLI is available." ) try: @@ -193,23 +208,25 @@ def _ensure_docker_available() -> None: ) except FileNotFoundError: logger.error( - "Docker backend selected but the resolved docker executable '%s' could " + "Container backend selected but the resolved executable '%s' could " "not be executed.", docker_exe, exc_info=True, ) raise RuntimeError( - "Docker executable could not be executed. Check your Docker installation." + f"{runtime_name()} executable could not be executed. " + f"Check your {runtime_name()} installation." ) except subprocess.TimeoutExpired: + _rt = runtime_name() logger.error( - "Docker backend selected but '%s version' timed out. " - "The Docker daemon may not be running.", - docker_exe, + "Container backend selected but '%s version' timed out. " + "The %s daemon may not be running.", + docker_exe, _rt, exc_info=True, ) raise RuntimeError( - "Docker daemon is not responding. Ensure Docker is running and try again." + f"{_rt} daemon is not responding. Ensure {_rt} is running and try again." ) except Exception: logger.error( @@ -219,16 +236,17 @@ def _ensure_docker_available() -> None: raise else: if result.returncode != 0: + _rt = runtime_name() logger.error( - "Docker backend selected but '%s version' failed " + "Container backend selected but '%s version' failed " "(exit code %d, stderr=%s)", docker_exe, result.returncode, result.stderr.strip(), ) raise RuntimeError( - "Docker command is available but 'docker version' failed. " - "Check your Docker installation." + f"{_rt} command is available but '{_rt.lower()} version' failed. " + f"Check your {_rt} installation." ) @@ -510,14 +528,19 @@ def _run_bash(self, cmd_string: str, *, login: bool = False, @staticmethod def _storage_opt_supported() -> bool: - """Check if Docker's storage driver supports --storage-opt size=. - - Only overlay2 on XFS with pquota supports per-container disk quotas. + """Check if the container runtime supports --storage-opt size=. + + Only Docker's overlay2 on XFS with pquota supports per-container disk + quotas. Podman does not support --storage-opt on containers at all. Ubuntu (and most distros) default to ext4, where this flag errors out. """ global _storage_opt_ok if _storage_opt_ok is not None: return _storage_opt_ok + # Podman does not support per-container --storage-opt size= + if is_podman(): + _storage_opt_ok = False + return False try: docker = find_docker() or "docker" result = subprocess.run(