DEVX-155: fix: prefer rootless Docker socket over low-space inner DinD daemon
Post-merge / detect-and-configure (push) Canceled after 0s
Post-merge / release-and-maintain (push) Canceled after 0s

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>
This commit is contained in:
gitea-actions-bot
2026-08-14 17:27:58 +02:00
committed by emil
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0341ee74c9
commit 23bd480e80
2 changed files with 188 additions and 48 deletions
+95 -30
View File
@@ -8,6 +8,8 @@ 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,
@@ -40,6 +42,42 @@ class TestIsDockerReady:
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)
@@ -88,61 +126,79 @@ class TestDiagnoseSocket:
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(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None:
"""Should return immediately if host Docker is available."""
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.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
@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 use rootless socket if host socket fails."""
# First check (host) fails, second check (rootless) succeeds
mock_ready.side_effect = [False, True]
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
"""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.os.path.exists", return_value=True)
@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_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
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 fails, own rootless fails, alt rootless succeeds
# 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=[alt_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.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_skips_own(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
@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 skip the own rootless socket in glob scan (already tried)."""
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
mock_ready.side_effect = [False, False, False, False, False, False]
"""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"
alt_sock = "/run/user/999/docker.sock"
with (
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
patch("devx.molecule.start_docker.time.sleep"),
patch("devx.molecule.start_docker.subprocess.Popen"),
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
patch("builtins.open", mock_open(read_data="err")),
):
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
assert start_docker_daemon(timeout=2) is False
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")
@@ -155,6 +211,7 @@ class TestStartDockerDaemon:
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
@@ -171,6 +228,7 @@ class TestStartDockerDaemon:
@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")
@@ -183,6 +241,7 @@ class TestStartDockerDaemon:
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
@@ -194,6 +253,7 @@ class TestStartDockerDaemon:
@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")
@@ -206,6 +266,7 @@ class TestStartDockerDaemon:
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
@@ -216,6 +277,7 @@ class TestStartDockerDaemon:
@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")
@@ -228,6 +290,7 @@ class TestStartDockerDaemon:
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
@@ -239,12 +302,14 @@ class TestStartDockerDaemon:
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."""