Files
devx/tests/unit/test_start_docker.py
T
gitea-actions-botandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 23bd480e80
Post-merge / detect-and-configure (push) Canceled after 0s
Post-merge / release-and-maintain (push) Canceled after 0s
DEVX-155: fix: prefer rootless Docker socket over low-space inner DinD daemon
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>
2026-08-14 17:27:58 +02:00

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