Compare commits

..
4 Commits
Author SHA1 Message Date
devx-ci-bot 6985030a3c release: v0.9.8 [skip ci] 2026-06-24 11:15:20 +02:00
emil 4d073f3beb DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 42s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 41s
2026-06-24 09:14:27 +00:00
devx-ci-bot 037d7b0d16 release: v0.9.7 [skip ci] 2026-06-24 02:18:19 +00:00
emil 9cb706e387 DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 42s
2026-06-24 02:17:20 +00:00
5 changed files with 160 additions and 21 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-27 DEVX-29
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.9.8] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
## [0.9.7] - 2026-06-24
### Bug Fixes
- Add rootless socket fallback and GITHUB_ENV export
## [0.9.6] - 2026-06-24 ## [0.9.6] - 2026-06-24
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.9.6" __version__ = "0.9.8"
+37 -5
View File
@@ -6,8 +6,9 @@ Docker socket mounted. This module verifies Docker is accessible and
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
connects to the same socket as the Docker CLI. connects to the same socket as the Docker CLI.
If the host socket is not available, it starts a local ``dockerd`` If the host socket is not available, it tries the rootless socket, then
with the vfs storage driver (requires privileged container). starts a local ``dockerd`` with the vfs storage driver (requires
privileged container).
Usage:: Usage::
@@ -16,6 +17,7 @@ Usage::
from __future__ import annotations from __future__ import annotations
import glob
import os import os
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -28,15 +30,18 @@ from devx.i18n import _
DEFAULT_TIMEOUT = 30 DEFAULT_TIMEOUT = 30
DOCKER_SOCK = "/var/run/docker.sock" DOCKER_SOCK = "/var/run/docker.sock"
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
def is_docker_ready() -> bool: def is_docker_ready() -> bool:
"""Check if Docker daemon is responding on the configured socket.""" """Check if Docker daemon is responding on the configured socket."""
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
result = subprocess.run( # nosec B603 B607 result = subprocess.run( # nosec B603 B607
["docker", "info"], ["docker", "info"],
capture_output=True, capture_output=True,
check=False, check=False,
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, env={**os.environ, "DOCKER_HOST": docker_host},
) )
return result.returncode == 0 return result.returncode == 0
@@ -93,8 +98,9 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Ensure Docker is ready for molecule tests. """Ensure Docker is ready for molecule tests.
First tries the host socket. If that works, sets ``DOCKER_HOST`` and First tries the host socket. If that works, sets ``DOCKER_HOST`` and
returns immediately. If not, starts a local ``dockerd`` with vfs returns immediately. If not, tries the rootless socket. If neither
storage driver (requires privileged container). works, starts a local ``dockerd`` with vfs storage driver (requires
privileged container).
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.
@@ -112,8 +118,28 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
click.echo(_("Docker daemon already running")) click.echo(_("Docker daemon already running"))
return True return True
# Try rootless socket (e.g. /run/user/994/docker.sock)
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
# Scan for any rootless sockets at other UIDs
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
if sock == ROOTLESS_SOCK:
continue
click.echo(f"Trying alternative rootless socket: {sock}")
os.environ["DOCKER_HOST"] = f"unix://{sock}"
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Host Docker not available, starting local dockerd...")) click.echo(_("Host Docker not available, starting local dockerd..."))
# Reset DOCKER_HOST to host socket for local dockerd
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Start local dockerd (requires privileged container) # Start local dockerd (requires privileged container)
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115 log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
mode="w", suffix="dockerd.log", delete=False mode="w", suffix="dockerd.log", delete=False
@@ -162,6 +188,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
def main(timeout: int) -> None: def main(timeout: int) -> None:
"""Start Docker daemon for CI molecule tests.""" """Start Docker daemon for CI molecule tests."""
if start_docker_daemon(timeout): if start_docker_daemon(timeout):
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
github_env = os.environ.get("GITHUB_ENV")
if github_env and os.environ.get("DOCKER_HOST"):
with open(github_env, "a") as f:
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)
+109 -14
View File
@@ -1,5 +1,6 @@
"""Unit tests for devx.molecule.start_docker.""" """Unit tests for devx.molecule.start_docker."""
import os
from unittest.mock import MagicMock, mock_open, patch from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner from click.testing import CliRunner
@@ -17,7 +18,8 @@ class TestIsDockerReady:
@patch("devx.molecule.start_docker.subprocess.run") @patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None: def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
assert is_docker_ready() is True 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 call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["docker", "info"] assert call_kwargs.args[0] == ["docker", "info"]
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}" assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
@@ -27,6 +29,16 @@ class TestIsDockerReady:
mock_run.return_value = MagicMock(returncode=1) mock_run.return_value = MagicMock(returncode=1)
assert is_docker_ready() is False 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 TestDiagnoseSocket: class TestDiagnoseSocket:
@patch("devx.molecule.start_docker.os.stat") @patch("devx.molecule.start_docker.os.stat")
@@ -68,20 +80,71 @@ class TestStartDockerDaemon:
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.time.sleep") @patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.is_docker_ready")
def test_rootless_socket_available(
self, mock_ready: MagicMock, mock_exists: 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=[]):
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.is_docker_ready")
def test_alt_rootless_socket_found(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should find rootless socket at a different UID via glob scan."""
# Host fails, own rootless fails, alt rootless succeeds
mock_ready.side_effect = [False, False, True]
alt_sock = "/run/user/999/docker.sock"
with patch("devx.molecule.start_docker.glob.glob", return_value=[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.is_docker_ready")
def test_alt_rootless_socket_skips_own(
self, mock_ready: MagicMock, mock_exists: 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]
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
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@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.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_starts_local_daemon( def test_starts_local_daemon(
self, self,
mock_ntf: MagicMock, mock_ntf: MagicMock,
mock_popen: MagicMock, mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock, mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock, mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None: ) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
mock_ready.side_effect = [False, False, False, True] # 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 assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once() mock_popen.assert_called_once()
popen_args = mock_popen.call_args.args[0] popen_args = mock_popen.call_args.args[0]
@@ -89,21 +152,23 @@ class TestStartDockerDaemon:
assert "--storage-driver" in popen_args assert "--storage-driver" in popen_args
assert "vfs" in popen_args assert "vfs" in popen_args
assert "-H" in popen_args assert "-H" in popen_args
assert f"unix://{DOCKER_SOCK}" in popen_args
assert mock_sleep.call_count == 2
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.time.sleep") @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.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.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_after_timeout( def test_fails_after_timeout(
self, self,
mock_ntf: MagicMock, mock_ntf: MagicMock,
mock_popen: MagicMock, mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock, mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock, mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None: ) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", mock_open(read_data="dockerd error log")): with patch("builtins.open", mock_open(read_data="dockerd error log")):
@@ -111,42 +176,51 @@ class TestStartDockerDaemon:
mock_popen.assert_called_once() mock_popen.assert_called_once()
assert mock_sleep.call_count == 3 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._diagnose_socket")
@patch("devx.molecule.start_docker.time.sleep") @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.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.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_log_read_error( def test_fails_log_read_error(
self, self,
mock_ntf: MagicMock, mock_ntf: MagicMock,
mock_popen: MagicMock, mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock, mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock, mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None: ) -> None:
"""Should handle log read errors gracefully.""" """Should handle log read errors gracefully."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", side_effect=OSError("permission denied")): with patch("builtins.open", side_effect=OSError("permission denied")):
assert start_docker_daemon(timeout=2) is False 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._diagnose_socket")
@patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready") @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.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_local_daemon_ready_on_first_check( def test_local_daemon_ready_on_first_check(
self, self,
mock_ntf: MagicMock, mock_ntf: MagicMock,
mock_popen: MagicMock, mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock, mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock, mock_diag: 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
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
mock_popen.assert_called_once() assert mock_popen.call_count == 1
mock_sleep.assert_called_once_with(1) assert mock_sleep.call_count == 1
@patch("devx.molecule.start_docker._diagnose_socket") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.environ") @patch("devx.molecule.start_docker.os.environ")
@@ -181,3 +255,24 @@ class TestMain:
result = runner.invoke(main, ["--timeout", "60"]) result = runner.invoke(main, ["--timeout", "60"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_start.assert_called_once_with(60) mock_start.assert_called_once_with(60)
@patch("devx.molecule.start_docker.os.environ.get")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
mock_get.side_effect = lambda key, default="": (
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
)
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")
@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