DEVX-155: fix: prefer /run/host-docker.sock over inner dockerd

The start_docker.py script was checking /var/run/docker.sock first,
which inside CI containers is an inner dockerd (v29.5.3) with data
root on the container's 38G overlay (often 100% full). When
_get_docker_free_bytes() returned 0 (data root path not accessible
from inside container), the script assumed it was the host Docker
with plenty of space and returned True immediately — without trying
the host's rootless Docker socket at /run/host-docker.sock.

Fix: try /run/host-docker.sock FIRST (before /var/run/docker.sock).
The host socket is mounted by the gitea runner config and has access
to the host's full filesystem (455G). Only trust free_bytes == 0
(= data root not accessible from container) for /run/host-docker.sock,
since the host's root dir is genuinely outside the container. For
other sockets (inner dockerd), free_bytes == 0 means the path doesn't
exist inside the container — don't trust it.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
emil
2026-08-14 23:15:03 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 1470cdae27
commit 0e25810b84
2 changed files with 189 additions and 91 deletions
+28 -37
View File
@@ -157,60 +157,41 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
Tries sockets in this order, preferring ones with enough disk space: Tries sockets in this order, preferring ones with enough disk space:
1. Host socket (``/var/run/docker.sock``) — if it has ≥ ``MIN_FREE_BYTES`` 1. Host rootless socket (``/run/host-docker.sock``) — mounted by the
free space, use it immediately. gitea runner config, has access to the host's full filesystem
2. Rootless sockets (``/run/user/*/docker.sock``) — if the host socket (e.g. 455 GB). Preferred over the inner dockerd.
has insufficient space, try rootless sockets which may have access 2. Default socket (``/var/run/docker.sock``) — may be an inner dockerd
to the host's full filesystem. started by the CI image (v29.5.3) with data root on the container's
3. Local ``dockerd`` with vfs storage driver — last resort. limited overlay (e.g. 38 GB, often 100 % full).
3. Other rootless sockets (``/run/user/*/docker.sock``).
4. Local ``dockerd`` with vfs storage driver — last resort.
Returns ``True`` if Docker is ready, ``False`` if it failed to Returns ``True`` if Docker is ready, ``False`` if it failed to
start within the timeout. start within the timeout.
""" """
# Point Docker CLI and Python library to the socket explicitly
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Diagnose socket state # Diagnose socket state
click.echo("--- Docker socket diagnostics ---") click.echo("--- Docker socket diagnostics ---")
_diagnose_socket() _diagnose_socket()
click.echo("--- End diagnostics ---") click.echo("--- End diagnostics ---")
# Check if host Docker is already available # Collect candidate sockets in priority order.
if is_docker_ready(): # The host's rootless Docker socket (mounted at /run/host-docker.sock
free_bytes = _get_docker_free_bytes() # by the gitea runner config) is preferred — it has access to the
free_gb = free_bytes / 1024**3 # host's full filesystem instead of the container's limited overlay.
click.echo(f"Docker daemon already running (free space: {free_gb:.1f} GB)")
if free_bytes >= MIN_FREE_BYTES:
return True
# If free_bytes is 0, the Docker root dir is on the host filesystem
# (not accessible from inside the container). The host Docker has
# access to the full 455G disk — always use it in that case.
if free_bytes == 0:
click.echo("Host Docker root dir not accessible from container, using host Docker")
return True
click.echo(
f"Host Docker has only {free_gb:.1f} GB free "
f"(need ≥ {MIN_FREE_BYTES / 1024**3:.0f} GB), trying rootless sockets..."
)
else:
click.echo("Host Docker not available, trying rootless sockets...")
# Collect candidate rootless sockets
candidates: list[str] = [] candidates: list[str] = []
# The host Docker socket is mounted by the gitea_runner config.
# This is the preferred candidate — it has access to the host's
# full filesystem instead of the container's limited overlay.
if os.path.exists(HOST_DOCKER_SOCK): if os.path.exists(HOST_DOCKER_SOCK):
candidates.append(HOST_DOCKER_SOCK) candidates.append(HOST_DOCKER_SOCK)
if os.path.exists(DOCKER_SOCK):
candidates.append(DOCKER_SOCK)
if os.path.exists(ROOTLESS_SOCK): if os.path.exists(ROOTLESS_SOCK):
candidates.append(ROOTLESS_SOCK) candidates.append(ROOTLESS_SOCK)
for sock in sorted(glob.glob("/run/user/*/docker.sock")): for sock in sorted(glob.glob("/run/user/*/docker.sock")):
if sock not in candidates: if sock not in candidates:
candidates.append(sock) candidates.append(sock)
# Try each rootless socket — prefer one with enough free space # Try each candidate socket — prefer one with enough free space
for sock in candidates: for sock in candidates:
click.echo(f"Trying rootless socket: {sock}") click.echo(f"Trying socket: {sock}")
if not _try_socket(sock): if not _try_socket(sock):
continue continue
free_bytes = _get_docker_free_bytes() free_bytes = _get_docker_free_bytes()
@@ -219,10 +200,20 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
if free_bytes >= MIN_FREE_BYTES: if free_bytes >= MIN_FREE_BYTES:
click.echo(_("Docker daemon already running")) click.echo(_("Docker daemon already running"))
return True return True
# If free_bytes is 0, the Docker root dir is on the host filesystem
# (not accessible from inside the container). This is expected for
# the host's rootless Docker — it has the full host disk.
# Only trust this for /run/host-docker.sock (known host socket).
# For other sockets (e.g. inner dockerd), free_bytes == 0 means
# the data root path doesn't exist inside the container — the
# inner dockerd may be using the container's full overlay.
if free_bytes == 0 and sock == HOST_DOCKER_SOCK:
click.echo("Host rootless Docker root dir not accessible from container, using it")
return True
click.echo(f" Insufficient space ({free_gb:.1f} GB), trying next...") click.echo(f" Insufficient space ({free_gb:.1f} GB), trying next...")
# If we found a working rootless socket but with low space, use it # If we found a working socket but with low space, use the last
# as a fallback (better than nothing). # one as a fallback (better than nothing).
for sock in reversed(candidates): for sock in reversed(candidates):
if _try_socket(sock): if _try_socket(sock):
click.echo(f"Using low-space fallback: {sock}") click.echo(f"Using low-space fallback: {sock}")
+161 -54
View File
@@ -7,6 +7,8 @@ from click.testing import CliRunner
from devx.molecule.start_docker import ( from devx.molecule.start_docker import (
DOCKER_SOCK, DOCKER_SOCK,
HOST_DOCKER_SOCK,
ROOTLESS_SOCK,
_diagnose_socket, _diagnose_socket,
_get_docker_free_bytes, _get_docker_free_bytes,
_try_socket, _try_socket,
@@ -124,91 +126,192 @@ class TestDiagnoseSocket:
mock_exists.assert_called_with(DOCKER_SOCK) 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: class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket") @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._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.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( def test_host_socket_available_with_space(
self, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
"""Should return immediately if host Docker has enough space.""" """Should return immediately if /var/run/docker.sock has enough space."""
assert start_docker_daemon(timeout=5) is True assert start_docker_daemon(timeout=5) is True
mock_ready.assert_called_once()
mock_diag.assert_called_once() mock_diag.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0) @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.is_docker_ready", return_value=True)
def test_host_socket_inaccessible_root_uses_host( @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({HOST_DOCKER_SOCK}))
self, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock @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: ) -> None:
"""Should use host Docker when root dir is inaccessible (free=0).""" """Should use host rootless Docker when root dir is inaccessible (free=0)."""
assert start_docker_daemon(timeout=5) is True assert start_docker_daemon(timeout=5) is True
mock_ready.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket") @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._get_docker_free_bytes", return_value=0)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_host_socket_low_space_tries_rootless( @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
self, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock @patch("devx.molecule.start_docker.glob.glob", return_value=[])
@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_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
"""Should try rootless sockets if host Docker has low space.""" """Inner dockerd with free=0 (not host socket) should NOT be trusted — start local."""
with ( mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
patch("devx.molecule.start_docker.glob.glob", return_value=[]), # is_docker_ready: first check (inner dockerd) True, then local daemon checks
patch("devx.molecule.start_docker.os.path.exists", return_value=False), mock_ready.side_effect = [True, False, False, False, False, True]
patch("devx.molecule.start_docker.subprocess.run"), assert start_docker_daemon(timeout=5) is True
patch("devx.molecule.start_docker.subprocess.Popen"), mock_popen.assert_called_once()
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile"),
patch("devx.molecule.start_docker.time.sleep"),
):
# Host ready but low space, no rootless sockets, starts local dockerd
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
def test_rootless_socket_with_space( @patch("devx.molecule.start_docker.glob.glob", return_value=[])
self, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock @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_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
"""Should use rootless socket if host has low space and rootless has enough.""" """Should start local dockerd if host Docker has low space and no rootless sockets."""
# Host ready but low space, rootless ready with enough 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._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_ready.side_effect = [True, True]
mock_free.side_effect = [5 * 1024**3, 200 * 1024**3] mock_free.side_effect = [5 * 1024**3, 200 * 1024**3]
own_sock = f"/run/user/{os.getuid()}/docker.sock" assert start_docker_daemon(timeout=5) is True
with patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock]): assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker._get_docker_free_bytes") @patch("devx.molecule.start_docker._get_docker_free_bytes")
@patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True) @patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
def test_alt_rootless_socket_found( @patch("devx.molecule.start_docker.glob.glob", return_value=[])
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( def test_all_sockets_low_space_falls_back(
self, mock_exists: MagicMock, mock_ready: MagicMock, mock_free: MagicMock, mock_diag: MagicMock self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock,
mock_free: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
"""Should fall back to a low-space socket if no better option exists.""" """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_ready.side_effect = [True, True, True, True]
mock_free.side_effect = [5 * 1024**3, 5 * 1024**3, 5 * 1024**3, 5 * 1024**3] 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" assert start_docker_daemon(timeout=5) is True
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.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@@ -232,7 +335,7 @@ class TestStartDockerDaemon:
mock_glob: MagicMock, mock_glob: MagicMock,
) -> None: ) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local daemon starts # No sockets exist, local daemon starts
mock_ready.side_effect = [False, False, False, False, True] mock_ready.side_effect = [False, False, False, False, True]
assert start_docker_daemon(timeout=5) is True assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once() mock_popen.assert_called_once()
@@ -317,18 +420,22 @@ class TestStartDockerDaemon:
mock_glob: MagicMock, mock_glob: MagicMock,
) -> None: ) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local ready on first loop check # No sockets exist, local ready on second loop check
mock_ready.side_effect = [False, False, True] mock_ready.side_effect = [False, False, True]
assert start_docker_daemon(timeout=5) is True assert start_docker_daemon(timeout=5) is True
assert mock_popen.call_count == 1 assert mock_popen.call_count == 1
assert mock_sleep.call_count == 1 assert mock_sleep.call_count == 2
@patch("devx.molecule.start_docker._diagnose_socket") @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._get_docker_free_bytes", return_value=100 * 1024**3)
@patch("devx.molecule.start_docker.os.environ") @patch("devx.molecule.start_docker.os.environ")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True) @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( def test_sets_docker_host(
self, self,
mock_glob: MagicMock,
mock_exists: MagicMock,
mock_ready: MagicMock, mock_ready: MagicMock,
mock_environ: MagicMock, mock_environ: MagicMock,
mock_free: MagicMock, mock_free: MagicMock,