Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a regression test for this Podman-only path: make Docker unavailable, resolve Podman here, and assert doctor reports Podman and invokes the resolved executable for info. The added doctor test currently only covers the Termux case where both runtimes are absent.

_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":
Expand Down
17 changes: 12 additions & 5 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions tests/hermes_cli/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions tests/tools/test_docker_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
)

Expand Down
67 changes: 67 additions & 0 deletions tests/tools/test_docker_find.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
57 changes: 40 additions & 17 deletions tools/environments/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$")


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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."
)


Expand Down Expand Up @@ -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(
Expand Down