Public Access
When a CI container has an inner dockerd (DinD) writing to the container's overlay (e.g. 38 GB), image pulls fail with ENOSPC. start_docker.py now checks the daemon's free disk space and tries rootless sockets (which have access to the host's full filesystem) when the default socket has less than 20 GB free. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
358 lines
17 KiB
Python
358 lines
17 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,
|
|
_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)
|
|
|
|
|
|
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)
|
|
def test_host_socket_available_with_space(
|
|
self, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock
|
|
) -> None:
|
|
"""Should return immediately if host Docker has enough space."""
|
|
assert start_docker_daemon(timeout=5) is True
|
|
mock_ready.assert_called_once()
|
|
mock_diag.assert_called_once()
|
|
|
|
@patch("devx.molecule.start_docker._diagnose_socket")
|
|
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=5 * 1024**3)
|
|
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
|
def test_host_socket_low_space_tries_rootless(
|
|
self, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock
|
|
) -> None:
|
|
"""Should try rootless sockets if host Docker has low space."""
|
|
with (
|
|
patch("devx.molecule.start_docker.glob.glob", return_value=[]),
|
|
patch("devx.molecule.start_docker.os.path.exists", return_value=False),
|
|
):
|
|
# Host ready but low space, no rootless sockets, falls back to host
|
|
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", return_value=True)
|
|
def test_rootless_socket_with_space(
|
|
self, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock
|
|
) -> None:
|
|
"""Should use rootless socket if host has low space and rootless has enough."""
|
|
# Host ready but low space, rootless ready with enough space
|
|
mock_ready.side_effect = [True, True]
|
|
mock_free.side_effect = [5 * 1024**3, 200 * 1024**3]
|
|
own_sock = f"/run/user/{os.getuid()}/docker.sock"
|
|
with patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock]):
|
|
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", return_value=True)
|
|
def test_alt_rootless_socket_found(
|
|
self, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock
|
|
) -> None:
|
|
"""Should find rootless socket at a different UID via glob scan."""
|
|
# Host not ready, own rootless not ready, alt rootless ready with space
|
|
mock_ready.side_effect = [False, False, True]
|
|
mock_free.side_effect = [200 * 1024**3]
|
|
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]):
|
|
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", return_value=True)
|
|
def test_all_sockets_low_space_falls_back(
|
|
self, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock
|
|
) -> None:
|
|
"""Should fall back to a low-space socket if no better option exists."""
|
|
# Host ready low space, rootless ready low space, falls back to rootless
|
|
mock_ready.side_effect = [True, True, True, True]
|
|
mock_free.side_effect = [5 * 1024**3, 5 * 1024**3, 5 * 1024**3, 5 * 1024**3]
|
|
own_sock = f"/run/user/{os.getuid()}/docker.sock"
|
|
with patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock]):
|
|
assert start_docker_daemon(timeout=5) is True
|
|
|
|
@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.Popen")
|
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
|
def test_starts_local_daemon(
|
|
self,
|
|
mock_ntf: MagicMock,
|
|
mock_popen: 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")
|
|
# 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]
|
|
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.Popen")
|
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
|
def test_fails_after_timeout(
|
|
self,
|
|
mock_ntf: MagicMock,
|
|
mock_popen: 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 == 3
|
|
|
|
@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.Popen")
|
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
|
def test_fails_log_read_error(
|
|
self,
|
|
mock_ntf: MagicMock,
|
|
mock_popen: 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.Popen")
|
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
|
def test_local_daemon_ready_on_first_check(
|
|
self,
|
|
mock_ntf: MagicMock,
|
|
mock_popen: 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")
|
|
# 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
|
|
assert mock_popen.call_count == 1
|
|
assert mock_sleep.call_count == 1
|
|
|
|
@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)
|
|
def test_sets_docker_host(
|
|
self,
|
|
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
|