From 9cb706e387a693d46c0ef9dd11b1f2b729a259e0 Mon Sep 17 00:00:00 2001 From: emil Date: Wed, 24 Jun 2026 02:17:20 +0000 Subject: [PATCH] DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export --- .taskid | 2 +- src/devx/molecule/start_docker.py | 25 ++++++++++-- tests/unit/test_start_docker.py | 66 +++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/.taskid b/.taskid index 4fcb4cb..afaaa97 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-27 +DEVX-28 diff --git a/src/devx/molecule/start_docker.py b/src/devx/molecule/start_docker.py index bae250a..56bab5f 100644 --- a/src/devx/molecule/start_docker.py +++ b/src/devx/molecule/start_docker.py @@ -6,8 +6,9 @@ Docker socket mounted. This module verifies Docker is accessible and sets ``DOCKER_HOST`` explicitly so molecule's Python docker library connects to the same socket as the Docker CLI. -If the host socket is not available, it starts a local ``dockerd`` -with the vfs storage driver (requires privileged container). +If the host socket is not available, it tries the rootless socket, then +starts a local ``dockerd`` with the vfs storage driver (requires +privileged container). Usage:: @@ -28,6 +29,8 @@ from devx.i18n import _ DEFAULT_TIMEOUT = 30 DOCKER_SOCK = "/var/run/docker.sock" +# Rootless socket fallback (e.g. /run/user/994/docker.sock) +ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock" def is_docker_ready() -> bool: @@ -93,8 +96,9 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: """Ensure Docker is ready for molecule tests. First tries the host socket. If that works, sets ``DOCKER_HOST`` and - returns immediately. If not, starts a local ``dockerd`` with vfs - storage driver (requires privileged container). + returns immediately. If not, tries the rootless socket. If neither + works, starts a local ``dockerd`` with vfs storage driver (requires + privileged container). Returns ``True`` if Docker is ready, ``False`` if it failed to start within the timeout. @@ -112,6 +116,13 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: click.echo(_("Docker daemon already running")) return True + # Try rootless socket (e.g. /run/user/994/docker.sock) + click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}") + os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}" + if os.path.exists(ROOTLESS_SOCK) and is_docker_ready(): + click.echo(_("Docker daemon already running")) + return True + click.echo(_("Host Docker not available, starting local dockerd...")) # Start local dockerd (requires privileged container) @@ -162,6 +173,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: def main(timeout: int) -> None: """Start Docker daemon for CI molecule tests.""" if start_docker_daemon(timeout): + # Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps + github_env = os.environ.get("GITHUB_ENV") + if github_env and os.environ.get("DOCKER_HOST"): + with open(github_env, "a") as f: + f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n") + click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV") sys.exit(0) sys.exit(1) diff --git a/tests/unit/test_start_docker.py b/tests/unit/test_start_docker.py index 6329f64..fb97076 100644 --- a/tests/unit/test_start_docker.py +++ b/tests/unit/test_start_docker.py @@ -68,20 +68,34 @@ class TestStartDockerDaemon: mock_diag.assert_called_once() @patch("devx.molecule.start_docker._diagnose_socket") - @patch("devx.molecule.start_docker.time.sleep") + @patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.is_docker_ready") + def test_rootless_socket_available( + self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock + ) -> None: + """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 + + @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) + @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") def test_starts_local_daemon( self, mock_ntf: MagicMock, mock_popen: MagicMock, - mock_ready: MagicMock, mock_sleep: MagicMock, + mock_ready: MagicMock, + mock_exists: MagicMock, mock_diag: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") - mock_ready.side_effect = [False, False, False, True] + # Host fails, rootless doesn't exist, local daemon starts + mock_ready.side_effect = [False, False, False, False, True] assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() popen_args = mock_popen.call_args.args[0] @@ -89,20 +103,20 @@ class TestStartDockerDaemon: assert "--storage-driver" in popen_args assert "vfs" in popen_args assert "-H" in popen_args - assert f"unix://{DOCKER_SOCK}" in popen_args - assert mock_sleep.call_count == 2 @patch("devx.molecule.start_docker._diagnose_socket") - @patch("devx.molecule.start_docker.time.sleep") + @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) + @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") def test_fails_after_timeout( self, mock_ntf: MagicMock, mock_popen: MagicMock, - mock_ready: MagicMock, mock_sleep: MagicMock, + mock_ready: MagicMock, + mock_exists: MagicMock, mock_diag: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") @@ -112,16 +126,18 @@ class TestStartDockerDaemon: assert mock_sleep.call_count == 3 @patch("devx.molecule.start_docker._diagnose_socket") - @patch("devx.molecule.start_docker.time.sleep") + @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) + @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") def test_fails_log_read_error( self, mock_ntf: MagicMock, mock_popen: MagicMock, - mock_ready: MagicMock, mock_sleep: MagicMock, + mock_ready: MagicMock, + mock_exists: MagicMock, mock_diag: MagicMock, ) -> None: """Should handle log read errors gracefully.""" @@ -130,23 +146,26 @@ class TestStartDockerDaemon: assert start_docker_daemon(timeout=2) is False @patch("devx.molecule.start_docker._diagnose_socket") - @patch("devx.molecule.start_docker.time.sleep") + @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.is_docker_ready") + @patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") def test_local_daemon_ready_on_first_check( self, mock_ntf: MagicMock, mock_popen: MagicMock, - mock_ready: MagicMock, mock_sleep: MagicMock, + mock_ready: MagicMock, + mock_exists: MagicMock, mock_diag: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") + # Host fails, rootless doesn't exist, local ready on first loop check mock_ready.side_effect = [False, False, True] assert start_docker_daemon(timeout=5) is True - mock_popen.assert_called_once() - mock_sleep.assert_called_once_with(1) + assert mock_popen.call_count == 1 + assert mock_sleep.call_count == 1 @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker.os.environ") @@ -181,3 +200,24 @@ class TestMain: result = runner.invoke(main, ["--timeout", "60"]) assert result.exit_code == 0 mock_start.assert_called_once_with(60) + + @patch("devx.molecule.start_docker.os.environ.get") + @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) + def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None: + """Should write DOCKER_HOST to GITHUB_ENV when available.""" + mock_get.side_effect = lambda key, default="": ( + "/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default + ) + with patch("builtins.open", mock_open()) as mock_file: + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + mock_file.assert_called_with("/tmp/github_env", "a") + + @patch("devx.molecule.start_docker.os.environ.get", return_value="") + @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) + def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None: + """Should not crash when GITHUB_ENV is not set.""" + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0