From 4d073f3beba08e9f2bbeb17c5d1867ed4556abf1 Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 24 Jun 2026 09:14:27 +0000 Subject: [PATCH] DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets --- .taskid | 2 +- src/devx/molecule/start_docker.py | 17 ++++++++- tests/unit/test_start_docker.py | 59 +++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.taskid b/.taskid index afaaa97..ab29330 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-28 +DEVX-29 \ No newline at end of file diff --git a/src/devx/molecule/start_docker.py b/src/devx/molecule/start_docker.py index 56bab5f..5f5f86a 100644 --- a/src/devx/molecule/start_docker.py +++ b/src/devx/molecule/start_docker.py @@ -17,6 +17,7 @@ Usage:: from __future__ import annotations +import glob import os import subprocess # nosec B404 import sys @@ -35,11 +36,12 @@ ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock" def is_docker_ready() -> bool: """Check if Docker daemon is responding on the configured socket.""" + docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}") result = subprocess.run( # nosec B603 B607 ["docker", "info"], capture_output=True, check=False, - env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, + env={**os.environ, "DOCKER_HOST": docker_host}, ) return result.returncode == 0 @@ -123,8 +125,21 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: click.echo(_("Docker daemon already running")) return True + # Scan for any rootless sockets at other UIDs + for sock in sorted(glob.glob("/run/user/*/docker.sock")): + if sock == ROOTLESS_SOCK: + continue + click.echo(f"Trying alternative rootless socket: {sock}") + os.environ["DOCKER_HOST"] = f"unix://{sock}" + if is_docker_ready(): + click.echo(_("Docker daemon already running")) + return True + click.echo(_("Host Docker not available, starting local dockerd...")) + # Reset DOCKER_HOST to host socket for local dockerd + os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}" + # Start local dockerd (requires privileged container) log_file = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="w", suffix="dockerd.log", delete=False diff --git a/tests/unit/test_start_docker.py b/tests/unit/test_start_docker.py index fb97076..89aee7b 100644 --- a/tests/unit/test_start_docker.py +++ b/tests/unit/test_start_docker.py @@ -1,5 +1,6 @@ """Unit tests for devx.molecule.start_docker.""" +import os from unittest.mock import MagicMock, mock_open, patch from click.testing import CliRunner @@ -17,7 +18,8 @@ class TestIsDockerReady: @patch("devx.molecule.start_docker.subprocess.run") def test_ready(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0) - assert is_docker_ready() is True + with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False): + assert is_docker_ready() is True call_kwargs = mock_run.call_args assert call_kwargs.args[0] == ["docker", "info"] assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}" @@ -27,6 +29,16 @@ class TestIsDockerReady: mock_run.return_value = MagicMock(returncode=1) assert is_docker_ready() is False + @patch("devx.molecule.start_docker.subprocess.run") + def test_uses_docker_host_env(self, mock_run: MagicMock) -> None: + """Should check the socket specified by DOCKER_HOST env var.""" + mock_run.return_value = MagicMock(returncode=0) + rootless = "unix:///run/user/999/docker.sock" + with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False): + assert is_docker_ready() is True + call_kwargs = mock_run.call_args + assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless + class TestDiagnoseSocket: @patch("devx.molecule.start_docker.os.stat") @@ -76,8 +88,44 @@ class TestStartDockerDaemon: """Should use rootless socket if host socket fails.""" # First check (host) fails, second check (rootless) succeeds mock_ready.side_effect = [False, True] - assert start_docker_daemon(timeout=5) is True + with patch("devx.molecule.start_docker.glob.glob", return_value=[]): + assert start_docker_daemon(timeout=5) is True + @patch("devx.molecule.start_docker._diagnose_socket") + @patch("devx.molecule.start_docker.os.path.exists", return_value=True) + @patch("devx.molecule.start_docker.is_docker_ready") + def test_alt_rootless_socket_found( + self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock + ) -> None: + """Should find rootless socket at a different UID via glob scan.""" + # Host fails, own rootless fails, alt rootless succeeds + mock_ready.side_effect = [False, False, True] + alt_sock = "/run/user/999/docker.sock" + with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]): + assert start_docker_daemon(timeout=5) is True + + @patch("devx.molecule.start_docker._diagnose_socket") + @patch("devx.molecule.start_docker.os.path.exists", return_value=True) + @patch("devx.molecule.start_docker.is_docker_ready") + def test_alt_rootless_socket_skips_own( + self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock + ) -> None: + """Should skip the own rootless socket in glob scan (already tried).""" + # Host fails, own rootless fails, alt rootless also fails, dockerd fails + mock_ready.side_effect = [False, False, False, False, False, False] + own_sock = f"/run/user/{os.getuid()}/docker.sock" + alt_sock = "/run/user/999/docker.sock" + with ( + patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]), + patch("devx.molecule.start_docker.time.sleep"), + patch("devx.molecule.start_docker.subprocess.Popen"), + patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf, + patch("builtins.open", mock_open(read_data="err")), + ): + mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") + assert start_docker_daemon(timeout=2) is False + + @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) @@ -92,6 +140,7 @@ class TestStartDockerDaemon: mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock, + mock_glob: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") # Host fails, rootless doesn't exist, local daemon starts @@ -104,6 +153,7 @@ class TestStartDockerDaemon: assert "vfs" in popen_args assert "-H" in popen_args + @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) @@ -118,6 +168,7 @@ class TestStartDockerDaemon: mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock, + mock_glob: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") with patch("builtins.open", mock_open(read_data="dockerd error log")): @@ -125,6 +176,7 @@ class TestStartDockerDaemon: mock_popen.assert_called_once() assert mock_sleep.call_count == 3 + @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) @@ -139,12 +191,14 @@ class TestStartDockerDaemon: mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock, + mock_glob: MagicMock, ) -> None: """Should handle log read errors gracefully.""" mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") with patch("builtins.open", side_effect=OSError("permission denied")): assert start_docker_daemon(timeout=2) is False + @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready") @@ -159,6 +213,7 @@ class TestStartDockerDaemon: mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock, + mock_glob: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") # Host fails, rootless doesn't exist, local ready on first loop check