Skip to content

Commit 060aaa3

Browse files
committed
Support Podman fallback when Docker is unavailable
1 parent 3337204 commit 060aaa3

3 files changed

Lines changed: 120 additions & 6 deletions

File tree

docs/quickstart.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
### Using Podman instead of Docker
99

10-
VibePod auto-detects rootless Podman and applies the necessary user-namespace
11-
settings (`keep-id`) so workspace file permissions work correctly.
10+
VibePod auto-detects common Podman sockets when Docker is unavailable,
11+
and applies the necessary rootless user-namespace settings (`keep-id`) so
12+
workspace file permissions work correctly.
1213

13-
Point VibePod at the Podman socket by setting `DOCKER_HOST`:
14+
If your Podman socket lives somewhere else, point VibePod at it by setting
15+
`DOCKER_HOST`:
1416

1517
```bash
1618
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock

src/vibepod/core/docker.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,45 @@ def _version_is_podman(version: Any) -> bool:
123123
return "podman" in str(version.get("Name", "")).lower()
124124

125125

126+
def _default_podman_socket_candidates() -> list[Path]:
127+
"""Return common Podman Docker-compatible API socket paths."""
128+
candidates: list[Path] = []
129+
130+
xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip()
131+
if xdg_runtime_dir:
132+
candidates.append(Path(xdg_runtime_dir) / "podman" / "podman.sock")
133+
134+
getuid = getattr(os, "getuid", None)
135+
if callable(getuid):
136+
candidates.append(Path("/run/user") / str(getuid()) / "podman" / "podman.sock")
137+
138+
candidates.append(Path("/run/podman/podman.sock"))
139+
140+
seen: set[str] = set()
141+
unique: list[Path] = []
142+
for candidate in candidates:
143+
key = str(candidate)
144+
if key in seen:
145+
continue
146+
seen.add(key)
147+
unique.append(candidate)
148+
return unique
149+
150+
151+
def _format_podman_socket_hint() -> str:
152+
candidates = ", ".join(f"unix://{path}" for path in _default_podman_socket_candidates())
153+
if shutil.which("podman"):
154+
return (
155+
" Podman appears to be installed, but VibePod could not connect to its "
156+
"Docker-compatible socket. Start the Podman socket or set DOCKER_HOST "
157+
f"to one of: {candidates}."
158+
)
159+
return (
160+
" Install/start Docker, or install Podman and expose its Docker-compatible socket "
161+
f"with DOCKER_HOST (for example: {candidates})."
162+
)
163+
164+
126165
def _parse_image_name(image: str) -> tuple[str, str | None]:
127166
"""Parse a full image string into repository and tag/digest."""
128167
if "@" in image:
@@ -141,13 +180,33 @@ class DockerManager:
141180
def __init__(self) -> None:
142181
if docker is None:
143182
raise DockerClientError("Docker SDK not installed")
183+
184+
self._rootless_podman: bool | None = None
144185
try:
145186
self.client = docker.from_env()
146187
self.client.ping()
147188
except DockerException as exc:
148-
raise DockerClientError(f"Docker is not available: {exc}") from exc
149-
150-
self._rootless_podman: bool | None = None
189+
podman_errors: list[str] = []
190+
docker_client = getattr(docker, "DockerClient", None)
191+
if callable(docker_client):
192+
for socket_path in _default_podman_socket_candidates():
193+
if not socket_path.exists():
194+
continue
195+
base_url = f"unix://{socket_path}"
196+
try:
197+
client = docker_client(base_url=base_url, version="auto")
198+
client.ping()
199+
except DockerException as podman_exc:
200+
podman_errors.append(f"{base_url}: {podman_exc}")
201+
continue
202+
self.client = client
203+
return
204+
205+
message = f"Docker is not available: {exc}"
206+
if podman_errors:
207+
message += " Podman socket attempts failed: " + "; ".join(podman_errors) + "."
208+
message += _format_podman_socket_hint()
209+
raise DockerClientError(message) from exc
151210

152211
def is_rootless_podman(self) -> bool:
153212
"""Return True for a rootless Podman engine exposed through the Docker API."""

tests/test_docker.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from vibepod.core.docker import (
1111
APIError,
1212
DockerClientError,
13+
DockerException,
1314
DockerManager,
1415
NotFound,
1516
_parse_image_name,
@@ -33,6 +34,58 @@ def test_parse_image_name() -> None:
3334
assert _parse_image_name("ubuntu") == ("ubuntu", None)
3435

3536

37+
@patch("vibepod.core.docker.docker")
38+
def test_docker_manager_falls_back_to_default_rootless_podman_socket(
39+
mock_docker, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
40+
) -> None:
41+
default_client = MagicMock()
42+
default_client.ping.side_effect = DockerException("default Docker socket missing")
43+
podman_client = MagicMock()
44+
mock_docker.from_env.return_value = default_client
45+
mock_docker.DockerClient.return_value = podman_client
46+
47+
runtime_dir = tmp_path / "runtime"
48+
podman_socket = runtime_dir / "podman" / "podman.sock"
49+
podman_socket.parent.mkdir(parents=True)
50+
podman_socket.touch()
51+
monkeypatch.delenv("DOCKER_HOST", raising=False)
52+
monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir))
53+
54+
manager = DockerManager()
55+
56+
assert manager.client is podman_client
57+
mock_docker.DockerClient.assert_called_once_with(
58+
base_url=f"unix://{podman_socket}", version="auto"
59+
)
60+
podman_client.ping.assert_called_once_with()
61+
62+
63+
@patch("vibepod.core.docker.docker")
64+
def test_docker_manager_falls_back_to_podman_when_docker_host_points_to_missing_docker(
65+
mock_docker, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
66+
) -> None:
67+
default_client = MagicMock()
68+
default_client.ping.side_effect = DockerException("configured Docker socket missing")
69+
podman_client = MagicMock()
70+
mock_docker.from_env.return_value = default_client
71+
mock_docker.DockerClient.return_value = podman_client
72+
73+
runtime_dir = tmp_path / "runtime"
74+
podman_socket = runtime_dir / "podman" / "podman.sock"
75+
podman_socket.parent.mkdir(parents=True)
76+
podman_socket.touch()
77+
monkeypatch.setenv("DOCKER_HOST", "unix:///var/run/docker.sock")
78+
monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir))
79+
80+
manager = DockerManager()
81+
82+
assert manager.client is podman_client
83+
mock_docker.DockerClient.assert_called_once_with(
84+
base_url=f"unix://{podman_socket}", version="auto"
85+
)
86+
podman_client.ping.assert_called_once_with()
87+
88+
3689
@patch("vibepod.core.docker.docker")
3790
def test_pull_image_success(mock_docker) -> None:
3891
# Set up mocks for DockerManager initialization

0 commit comments

Comments
 (0)