Compare commits

...
2 Commits
Author SHA1 Message Date
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 82 additions and 19 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-27 DEVX-28
+6
View File
@@ -2,6 +2,12 @@
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.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.7"
+21 -4
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::
@@ -28,6 +29,8 @@ 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:
@@ -93,8 +96,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,6 +116,13 @@ 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
click.echo(_("Host Docker not available, starting local dockerd...")) click.echo(_("Host Docker not available, starting local dockerd..."))
# Start local dockerd (requires privileged container) # Start local dockerd (requires privileged container)
@@ -162,6 +173,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)
+53 -13
View File
@@ -68,20 +68,34 @@ 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]
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=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,
) -> 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,20 +103,20 @@ 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._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,
) -> None: ) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log") mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
@@ -112,16 +126,18 @@ class TestStartDockerDaemon:
assert mock_sleep.call_count == 3 assert mock_sleep.call_count == 3
@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,
) -> None: ) -> None:
"""Should handle log read errors gracefully.""" """Should handle log read errors gracefully."""
@@ -130,23 +146,26 @@ class TestStartDockerDaemon:
assert start_docker_daemon(timeout=2) is False assert start_docker_daemon(timeout=2) is False
@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,
) -> 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 +200,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