Public Access
The host's rootless Docker socket is mounted as /var/run/docker.sock inside CI containers. Its data root is on the host filesystem (not accessible from inside the container), so _get_docker_free_bytes returns 0. Previously the code didn't trust this and started a local dockerd on /dev/shm (too small). Now checks pgrep for dockerd processes — if none found inside the container, the socket is the host's Docker and should be trusted. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
658 lines
29 KiB
Python
658 lines
29 KiB
Python
"""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,
|
|
)
|
|
|
|
|
|
def _pgrep_empty() -> MagicMock:
|
|
"""Mock for pgrep returning no dockerd processes."""
|
|
return MagicMock(stdout="", returncode=1)
|
|
|
|
|
|
def _docker_info_alive() -> MagicMock:
|
|
"""Mock for docker info showing daemon alive."""
|
|
return MagicMock(returncode=0)
|
|
|
|
|
|
def _docker_info_dead() -> MagicMock:
|
|
"""Mock for docker info showing daemon dead."""
|
|
return MagicMock(returncode=1)
|
|
|
|
|
|
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]
|
|
# subprocess.run: pgrep(socket check, finds dockerd), pgrep(before kill),
|
|
# pkill x3, pgrep(after kill), docker info(dead), rm
|
|
mock_run.side_effect = [
|
|
MagicMock(returncode=0, stdout="123 /usr/bin/dockerd\n"), # socket check pgrep
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
_pgrep_empty(),
|
|
_docker_info_dead(),
|
|
MagicMock(),
|
|
]
|
|
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", 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")
|
|
def test_no_inner_dockerd_trusts_host_socket(
|
|
self,
|
|
mock_run: MagicMock,
|
|
mock_glob: MagicMock,
|
|
mock_exists: MagicMock,
|
|
mock_ready: MagicMock,
|
|
mock_free: MagicMock,
|
|
mock_diag: MagicMock,
|
|
) -> None:
|
|
"""Should trust /var/run/docker.sock with free=0 when no inner dockerd exists."""
|
|
# pgrep finds no dockerd processes (returncode=1)
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
|
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_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
|
|
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(dead), rm
|
|
mock_run.side_effect = [
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
_pgrep_empty(),
|
|
_docker_info_dead(),
|
|
MagicMock(),
|
|
]
|
|
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_inner_dockerd_alive_uses_alt_sock(
|
|
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 use /dev/shm/docker.sock if inner dockerd can't be killed."""
|
|
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
|
|
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(alive), rm
|
|
mock_run.side_effect = [
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
_pgrep_empty(),
|
|
_docker_info_alive(),
|
|
MagicMock(),
|
|
]
|
|
assert start_docker_daemon(timeout=5) is True
|
|
mock_popen.assert_called_once()
|
|
assert os.environ.get("DOCKER_HOST") == "unix:///dev/shm/docker.sock"
|
|
|
|
@patch("devx.molecule.start_docker.os.kill")
|
|
@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_inner_dockerd_killed_by_pid(
|
|
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,
|
|
mock_kill: MagicMock,
|
|
) -> None:
|
|
"""Should kill dockerd by PID when pkill fails and pgrep finds processes."""
|
|
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
|
|
# pgrep(before) empty, pkill x3, pgrep(after) finds PID 12345, docker info(dead), rm
|
|
mock_run.side_effect = [
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(stdout="12345 /usr/bin/dockererd\n", returncode=0),
|
|
_docker_info_dead(),
|
|
MagicMock(),
|
|
]
|
|
assert start_docker_daemon(timeout=5) is True
|
|
mock_popen.assert_called_once()
|
|
mock_kill.assert_called_once_with(12345, 9)
|
|
|
|
@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
|
|
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(dead), rm
|
|
mock_run.side_effect = [
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
_pgrep_empty(),
|
|
_docker_info_dead(),
|
|
MagicMock(),
|
|
]
|
|
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]
|
|
# subprocess.run: pgrep(before), pkill x3, pgrep(after), rm
|
|
mock_run.side_effect = [
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
_pgrep_empty(),
|
|
MagicMock(),
|
|
]
|
|
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 == 5 # 1 after pkill + 1 after pgrep + 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 == 4 # 1 after pkill + 1 after pgrep + 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
|