"""Unit tests for devx.molecule.start_docker.""" import os from unittest.mock import MagicMock, mock_open, patch from click.testing import CliRunner from devx.molecule.start_docker import ( DOCKER_SOCK, HOST_DOCKER_SOCK, ROOTLESS_SOCK, _diagnose_socket, _get_docker_free_bytes, _try_socket, is_docker_ready, main, start_docker_daemon, ) class TestIsDockerReady: @patch("devx.molecule.start_docker.subprocess.run") def test_ready(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0) 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}" @patch("devx.molecule.start_docker.subprocess.run") def test_not_ready(self, mock_run: MagicMock) -> None: 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 TestGetDockerFreeBytes: @patch("devx.molecule.start_docker.shutil.disk_usage") @patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.subprocess.run") def test_returns_free_bytes(self, mock_run: MagicMock, mock_exists: MagicMock, mock_du: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="/var/lib/docker\n", returncode=0, text="") mock_du.return_value = MagicMock(free=50 * 1024**3) with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False): assert _get_docker_free_bytes() == 50 * 1024**3 @patch("devx.molecule.start_docker.subprocess.run") def test_daemon_not_reachable(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", text="") assert _get_docker_free_bytes() == 0 @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.subprocess.run") def test_data_root_not_accessible(self, mock_run: MagicMock, mock_exists: MagicMock) -> None: mock_run.return_value = MagicMock(stdout="/some/path\n", returncode=0, text="") assert _get_docker_free_bytes() == 0 @patch("devx.molecule.start_docker.subprocess.run", side_effect=FileNotFoundError) def test_subprocess_not_found(self, mock_run: MagicMock) -> None: assert _get_docker_free_bytes() == 0 class TestTrySocket: @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) def test_ready(self, mock_ready: MagicMock) -> None: assert _try_socket("/run/user/999/docker.sock") is True @patch("devx.molecule.start_docker.is_docker_ready", return_value=False) def test_not_ready(self, mock_ready: MagicMock) -> None: assert _try_socket("/run/user/999/docker.sock") is False class TestDiagnoseSocket: @patch("devx.molecule.start_docker.os.stat") @patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.subprocess.run") def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None: mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0) mock_run.side_effect = [ MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""), MagicMock(stdout="default\n", returncode=0, text=""), MagicMock( stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n", returncode=0, text="", ), ] _diagnose_socket() mock_exists.assert_called_with(DOCKER_SOCK) @patch("devx.molecule.start_docker.os.path.exists", return_value=False) @patch("devx.molecule.start_docker.subprocess.run") def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None: mock_run.side_effect = [ MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""), MagicMock(stdout="default\n", returncode=0, text=""), MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""), ] _diagnose_socket() mock_exists.assert_called_with(DOCKER_SOCK) @patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.os.stat") @patch("devx.molecule.start_docker.subprocess.run") def test_docker_info_no_matching_lines( self, mock_run: MagicMock, mock_stat: MagicMock, mock_exists: MagicMock ) -> None: """docker info succeeds but stdout has no Server Version/Storage Driver/Root Dir lines.""" mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0) mock_run.side_effect = [ MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""), MagicMock(stdout="default\n", returncode=0, text=""), MagicMock(stdout="Containers: 0\nImages: 0\nKernel: 6.1\n", returncode=0, text=""), ] _diagnose_socket() mock_exists.assert_called_with(DOCKER_SOCK) def _exists_map(paths: set[str]) -> MagicMock: """Return a mock os.path.exists that returns True only for *paths*.""" return MagicMock(side_effect=lambda p: p in paths) class TestStartDockerDaemon: @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3) @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_host_socket_available_with_space( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should return immediately if /var/run/docker.sock has enough space.""" assert start_docker_daemon(timeout=5) is True mock_diag.assert_called_once() @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0) @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({HOST_DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_host_rootless_inaccessible_root_uses_host( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should use host rootless Docker when root dir is inaccessible (free=0).""" assert start_docker_daemon(timeout=5) is True @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0) @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker.subprocess.run") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.time.sleep") def test_inner_dockerd_free_zero_starts_local( self, mock_sleep: MagicMock, mock_ntf: MagicMock, mock_popen: MagicMock, mock_run: MagicMock, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Inner dockerd with free=0 (not host socket) should NOT be trusted — start local.""" mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") # is_docker_ready: first check (inner dockerd) True, then local daemon checks mock_ready.side_effect = [True, False, False, False, False, True] assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker.subprocess.run") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.time.sleep") def test_host_socket_low_space_starts_local( self, mock_sleep: MagicMock, mock_ntf: MagicMock, mock_popen: MagicMock, mock_run: MagicMock, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should start local dockerd if host Docker has low space and no rootless sockets.""" mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ready.side_effect = [True, False, False, False, False, True] mock_free.return_value = 5 * 1024**3 assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_host_rootless_preferred_over_inner_dockerd( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should prefer /run/host-docker.sock over /var/run/docker.sock.""" # host rootless ready with space, inner dockerd never tried mock_ready.side_effect = [True] mock_free.side_effect = [200 * 1024**3] assert start_docker_daemon(timeout=5) is True # DOCKER_HOST should be set to host socket assert os.environ.get("DOCKER_HOST") == f"unix://{HOST_DOCKER_SOCK}" @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3) @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_host_rootless_not_ready_falls_to_inner( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should fall through to inner dockerd if host rootless socket is not ready.""" mock_ready.side_effect = [False, True] assert start_docker_daemon(timeout=5) is True assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}" @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3) @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch( "devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK, ROOTLESS_SOCK, "/run/user/999/docker.sock"}), ) @patch("devx.molecule.start_docker.glob.glob", return_value=["/run/user/999/docker.sock"]) def test_glob_finds_extra_rootless_socket( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should include rootless sockets found via glob scan.""" assert start_docker_daemon(timeout=5) is True @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_inner_dockerd_fallback_when_host_rootless_low_space( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should fall back to inner dockerd if host rootless has low space.""" # host rootless ready low space, inner dockerd ready with space mock_ready.side_effect = [True, True] mock_free.side_effect = [5 * 1024**3, 200 * 1024**3] assert start_docker_daemon(timeout=5) is True assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}" @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker.subprocess.run") @patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.time.sleep") def test_all_sockets_low_space_starts_local( self, mock_sleep: MagicMock, mock_ntf: MagicMock, mock_popen: MagicMock, mock_run: MagicMock, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """Should start local dockerd if all sockets have low space.""" mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ready.side_effect = [True, False, False, False, False, True] mock_free.return_value = 5 * 1024**3 assert start_docker_daemon(timeout=5) is True mock_popen.assert_called_once() @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0) @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.run") @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_run: MagicMock, mock_sleep: MagicMock, mock_ready: MagicMock, mock_exists: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, mock_glob: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") # No sockets 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] assert "dockerd" in popen_args assert "--storage-driver" in popen_args 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._get_docker_free_bytes", return_value=0) @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.run") @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_run: MagicMock, mock_sleep: MagicMock, mock_ready: MagicMock, mock_exists: MagicMock, mock_free: 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")): assert start_docker_daemon(timeout=3) is False mock_popen.assert_called_once() assert mock_sleep.call_count == 4 # 1 after pkill + 3 timeout retries @patch("devx.molecule.start_docker.glob.glob", return_value=[]) @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0) @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.run") @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_run: MagicMock, mock_sleep: MagicMock, mock_ready: MagicMock, mock_exists: MagicMock, mock_free: 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._get_docker_free_bytes", return_value=0) @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.run") @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_run: MagicMock, mock_sleep: MagicMock, mock_ready: MagicMock, mock_exists: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, mock_glob: MagicMock, ) -> None: mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") # No sockets exist, local ready on second loop check mock_ready.side_effect = [False, False, True] assert start_docker_daemon(timeout=5) is True assert mock_popen.call_count == 1 assert mock_sleep.call_count == 3 # 1 after pkill + 2 loop retries @patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3) @patch("devx.molecule.start_docker.os.environ") @patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK})) @patch("devx.molecule.start_docker.glob.glob", return_value=[]) def test_sets_docker_host( self, mock_glob: MagicMock, mock_exists: MagicMock, mock_ready: MagicMock, mock_environ: MagicMock, mock_free: MagicMock, mock_diag: MagicMock, ) -> None: """DOCKER_HOST must be set so molecule connects to correct socket.""" start_docker_daemon(timeout=5) mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}") class TestMain: @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) def test_success(self, mock_start: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 @patch("devx.molecule.start_docker.start_docker_daemon", return_value=False) def test_failure(self, mock_start: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 1 @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) def test_custom_timeout_flag(self, mock_start: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--timeout", "60"]) assert result.exit_code == 0 mock_start.assert_called_once_with(60) @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) def test_exports_github_env(self, mock_start: MagicMock) -> None: """Should write DOCKER_HOST to GITHUB_ENV when available.""" env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"} with patch.dict("os.environ", env, clear=True): 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", encoding="utf-8") @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