diff --git a/.env.example b/.env.example index 0317296ba..76be6ce26 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,10 @@ # Only override here if you need to force a backend without touching config.yaml: # TERMINAL_ENV=local +# Override the container runtime binary (e.g. to use Podman instead of Docker). +# Useful on systems where Docker's storage driver is broken or unavailable. +# HERMES_DOCKER_BINARY=/usr/local/bin/podman + # Container images (for singularity/docker/modal backends) # TERMINAL_DOCKER_IMAGE=nikolaik/python-nodejs:python3.11-nodejs20 # TERMINAL_SINGULARITY_IMAGE=docker://nikolaik/python-nodejs:python3.11-nodejs20 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100644 new mode 100755 index dc1edd32c..c46497dcc --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,13 +1,14 @@ #!/bin/bash -# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes. +# Docker/Podman entrypoint: bootstrap config files into the mounted volume, then run hermes. set -e -HERMES_HOME="/opt/data" +HERMES_HOME="${HERMES_HOME:-/opt/data}" INSTALL_DIR="/opt/hermes" # --- Privilege dropping via gosu --- -# When started as root (the default), optionally remap the hermes user/group -# to match host-side ownership, fix volume permissions, then re-exec as hermes. +# When started as root (the default for Docker, or fakeroot in rootless Podman), +# optionally remap the hermes user/group to match host-side ownership, fix volume +# permissions, then re-exec as hermes. if [ "$(id -u)" = "0" ]; then if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "Changing hermes UID to $HERMES_UID" @@ -16,13 +17,19 @@ if [ "$(id -u)" = "0" ]; then if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then echo "Changing hermes GID to $HERMES_GID" - groupmod -g "$HERMES_GID" hermes + # -o allows non-unique GID (e.g. macOS GID 20 "staff" may already exist + # as "dialout" in the Debian-based container image) + groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true fi actual_hermes_uid=$(id -u hermes) if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing" - chown -R hermes:hermes "$HERMES_HOME" + # In rootless Podman the container's "root" is mapped to an unprivileged + # host UID — chown will fail. That's fine: the volume is already owned + # by the mapped user on the host side. + chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \ + echo "Warning: chown failed (rootless container?) — continuing anyway" fi echo "Dropping root privileges" diff --git a/tests/tools/test_docker_find.py b/tests/tools/test_docker_find.py index c1fb58a3e..0cf9c3208 100644 --- a/tests/tools/test_docker_find.py +++ b/tests/tools/test_docker_find.py @@ -46,3 +46,59 @@ class TestFindDocker: with patch("tools.environments.docker.shutil.which", return_value=None): second = docker_mod.find_docker() assert first == second == "/usr/local/bin/docker" + + def test_env_var_override_takes_precedence(self, tmp_path): + """HERMES_DOCKER_BINARY overrides PATH and known-location discovery.""" + fake_binary = tmp_path / "podman" + fake_binary.write_text("#!/bin/sh\n") + fake_binary.chmod(0o755) + + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": str(fake_binary)}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == str(fake_binary) + + def test_env_var_override_ignored_if_not_executable(self, tmp_path): + """Non-executable HERMES_DOCKER_BINARY falls through to normal discovery.""" + fake_binary = tmp_path / "podman" + fake_binary.write_text("#!/bin/sh\n") + fake_binary.chmod(0o644) # not executable + + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": str(fake_binary)}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" + + def test_env_var_override_ignored_if_nonexistent(self): + """Non-existent HERMES_DOCKER_BINARY path falls through.""" + with patch.dict(os.environ, {"HERMES_DOCKER_BINARY": "/nonexistent/podman"}), \ + patch("tools.environments.docker.shutil.which", return_value="/usr/bin/docker"): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" + + def test_podman_on_path_used_when_docker_missing(self): + """When docker is not on PATH, podman is tried next.""" + def which_side_effect(name): + if name == "docker": + return None + 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", []): + result = docker_mod.find_docker() + assert result == "/usr/bin/podman" + + def test_docker_preferred_over_podman(self): + """When both docker and podman are on PATH, docker wins.""" + def which_side_effect(name): + if name == "docker": + return "/usr/bin/docker" + if name == "podman": + return "/usr/bin/podman" + return None + + with patch("tools.environments.docker.shutil.which", side_effect=which_side_effect): + result = docker_mod.find_docker() + assert result == "/usr/bin/docker" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 2341778f4..d2ea5c964 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -99,23 +99,41 @@ def _load_hermes_env_vars() -> dict[str, str]: def find_docker() -> Optional[str]: - """Locate the docker CLI binary. + """Locate the docker (or podman) CLI binary. - Checks ``shutil.which`` first (respects PATH), then probes well-known - install locations on macOS where Docker Desktop may not be in PATH - (e.g. when running as a gateway service via launchd). + Resolution order: + 1. ``HERMES_DOCKER_BINARY`` env var — explicit override (e.g. ``/usr/bin/podman``) + 2. ``docker`` on PATH via ``shutil.which`` + 3. ``podman`` on PATH via ``shutil.which`` + 4. Well-known macOS Docker Desktop install locations - Returns the absolute path, or ``None`` if docker cannot be found. + Returns the absolute path, or ``None`` if neither runtime can be found. """ global _docker_executable if _docker_executable is not None: return _docker_executable + # 1. Explicit override via env var (e.g. for Podman on immutable distros) + override = os.getenv("HERMES_DOCKER_BINARY") + if override and os.path.isfile(override) and os.access(override, os.X_OK): + _docker_executable = override + logger.info("Using HERMES_DOCKER_BINARY override: %s", override) + return override + + # 2. docker on PATH found = shutil.which("docker") if found: _docker_executable = found return found + # 3. podman on PATH (drop-in compatible for our use case) + found = shutil.which("podman") + if found: + _docker_executable = found + logger.info("Using podman as container runtime: %s", found) + return found + + # 4. Well-known macOS Docker Desktop locations for path in _DOCKER_SEARCH_PATHS: if os.path.isfile(path) and os.access(path, os.X_OK): _docker_executable = path