Compare commits

...
10 Commits
Author SHA1 Message Date
devx-ci-bot 5bd6158f2a release: v0.9.6 [skip ci] 2026-06-24 04:00:50 +02:00
emil 05aa2ffe76 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:59:55 +00:00
emil b9c3b55680 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 33s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 39s
2026-06-24 01:50:46 +00:00
devx-ci-bot d398c8e971 release: v0.9.5 [skip ci] 2026-06-24 01:37:15 +00:00
emil 39526d8e6a DEVX-26: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 46s
Post-merge / vikunja (push) Successful in 10s
Post-merge / badges (push) Successful in 41s
Post-merge / sync-wiki (push) Successful in 43s
2026-06-24 01:36:21 +00:00
emil 37730e2187 DEVX-25: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:24:00 +00:00
devx-ci-bot e2f66ca70a release: v0.9.4 [skip ci] 2026-06-24 01:11:08 +00:00
emil 0b88c211f1 DEVX-24: fix: use separate Docker socket for DinD in CI
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 43s
Post-merge / release (push) Successful in 51s
Post-merge / vikunja (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 44s
2026-06-24 01:08:10 +00:00
devx-ci-bot ea4ee0d303 release: v0.9.3 [skip ci] 2026-06-24 02:46:26 +02:00
emil 16fba17b03 DEVX-23: fix: use tempfile for dockerd log to fix CI permission error
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 40s
Post-merge / vikunja (push) Successful in 22s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 54s
2026-06-24 00:45:28 +00:00
6 changed files with 270 additions and 73 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-22 DEVX-27
+26
View File
@@ -2,6 +2,32 @@
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.6] - 2026-06-24
### Bug Fixes
- Add Docker socket diagnostics to start_docker
- Add Docker socket diagnostics to start_docker
## [0.9.5] - 2026-06-24
### Bug Fixes
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
## [0.9.4] - 2026-06-24
### Bug Fixes
- Use separate Docker socket for DinD in CI
## [0.9.3] - 2026-06-24
### Bug Fixes
- Use tempfile for dockerd log to fix CI permission error
## [0.9.2] - 2026-06-24 ## [0.9.2] - 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.2" __version__ = "0.9.6"
+102 -14
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Start a Docker daemon inside a CI runner container (Docker-in-Docker). """Ensure Docker is available for molecule tests in CI.
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
Docker socket mounted, but molecule needs a local Docker daemon to create Docker socket mounted. This module verifies Docker is accessible and
nested containers. This module always starts ``dockerd`` in the background sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
and waits for it to become ready. connects to the same socket as the Docker CLI.
If the host socket is not available, it starts a local ``dockerd``
with the vfs storage driver (requires privileged container).
Usage:: Usage::
@@ -13,8 +16,10 @@ Usage::
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
import tempfile
import time import time
import click import click
@@ -22,33 +27,106 @@ import click
from devx.i18n import _ from devx.i18n import _
DEFAULT_TIMEOUT = 30 DEFAULT_TIMEOUT = 30
DOCKERD_LOG = "/var/log/dockerd.log" DOCKER_SOCK = "/var/run/docker.sock"
def is_docker_ready() -> bool: def is_docker_ready() -> bool:
"""Check if the Docker daemon is responding.""" """Check if Docker daemon is responding on the configured socket."""
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}"},
) )
return result.returncode == 0 return result.returncode == 0
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool: def _diagnose_socket() -> None:
"""Start dockerd in the background and wait for it to be ready. """Print diagnostic info about the Docker socket."""
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
click.echo(f"Socket path: {DOCKER_SOCK}")
click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}")
if os.path.exists(DOCKER_SOCK):
stat = os.stat(DOCKER_SOCK)
click.echo(f"Socket mode: {oct(stat.st_mode)}")
click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}")
# Check if it's a mount point
result = subprocess.run( # nosec B603 B607
["mount"],
capture_output=True,
check=False,
text=True,
)
docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()]
if docker_mounts:
click.echo("Docker-related mounts:")
for line in docker_mounts:
click.echo(f" {line}")
else:
click.echo("No Docker-related mounts found")
# Check docker context
result = subprocess.run( # nosec B603 B607
["docker", "context", "ls"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"Docker contexts:\n{result.stdout}")
# Try docker info without DOCKER_HOST
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}")
if result.returncode != 0:
click.echo(f" stderr: {result.stderr[:500]}")
else:
# Print server version and storage driver
for line in result.stdout.splitlines():
if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line:
click.echo(f" {line.strip()}")
Always starts a local dockerd even if ``docker info`` succeeds,
because the host socket may be mounted but not suitable for def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
molecule's nested container creation. """Ensure Docker is ready for molecule tests.
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
returns immediately. If not, 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.
""" """
click.echo(_("Starting Docker daemon...")) # Point Docker CLI and Python library to the socket explicitly
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115 os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Diagnose socket state
click.echo("--- Docker socket diagnostics ---")
_diagnose_socket()
click.echo("--- End diagnostics ---")
# Check if host Docker is already available
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Host Docker not available, starting local dockerd..."))
# Start local dockerd (requires privileged container)
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
mode="w", suffix="dockerd.log", delete=False
)
click.echo(f"dockerd log: {log_file.name}")
subprocess.Popen( # nosec B603 B607 subprocess.Popen( # nosec B603 B607
["dockerd", "--storage-driver", "vfs"], [
"dockerd",
"--storage-driver",
"vfs",
"-H",
f"unix://{DOCKER_SOCK}",
],
stdout=log_file, stdout=log_file,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
start_new_session=True, start_new_session=True,
@@ -60,7 +138,17 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
return True return True
time.sleep(1) time.sleep(1)
# Print dockerd log on failure
click.echo(_("Docker daemon failed to start")) click.echo(_("Docker daemon failed to start"))
click.echo("--- dockerd log ---")
try:
with open(log_file.name) as f:
log_content = f.read()
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
except OSError as e:
click.echo(f"Could not read log: {e}")
click.echo("--- End dockerd log ---")
return False return False
+14 -7
View File
@@ -419,6 +419,13 @@
"ru": "Created release commit.", "ru": "Created release commit.",
"zh": "Created release commit." "zh": "Created release commit."
}, },
"Docker daemon already running": {
"bg": "Докер демонът вече работи",
"de": "Docker-Daemon läuft bereits",
"en": "Docker daemon already running",
"ru": "Демон Docker уже работает",
"zh": "Docker 守护进程已在运行"
},
"Docker daemon failed to start": { "Docker daemon failed to start": {
"bg": "Docker daemon failed to start", "bg": "Docker daemon failed to start",
"de": "Docker-Daemon konnte nicht gestartet werden", "de": "Docker-Daemon konnte nicht gestartet werden",
@@ -552,6 +559,13 @@
"ru": "Head branch is behind master. Pulling and rebasing...", "ru": "Head branch is behind master. Pulling and rebasing...",
"zh": "Head branch is behind master. Pulling and rebasing..." "zh": "Head branch is behind master. Pulling and rebasing..."
}, },
"Host Docker not available, starting local dockerd...": {
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
"en": "Host Docker not available, starting local dockerd...",
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
},
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
@@ -930,13 +944,6 @@
"ru": "Skipping commit push — no staged changes.", "ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes." "zh": "Skipping commit push — no staged changes."
}, },
"Starting Docker daemon...": {
"bg": "Starting Docker daemon...",
"de": "Docker-Daemon wird gestartet...",
"en": "Starting Docker daemon...",
"ru": "Запуск Docker-демона...",
"zh": "正在启动 Docker 守护进程..."
},
"Syncing {count} documentation pages to wiki...": { "Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...", "bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...", "de": "Syncing {count} documentation pages to wiki...",
+126 -50
View File
@@ -4,7 +4,13 @@ from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner from click.testing import CliRunner
from devx.molecule.start_docker import is_docker_ready, main, start_docker_daemon from devx.molecule.start_docker import (
DOCKER_SOCK,
_diagnose_socket,
is_docker_ready,
main,
start_docker_daemon,
)
class TestIsDockerReady: class TestIsDockerReady:
@@ -12,7 +18,9 @@ class TestIsDockerReady:
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 assert is_docker_ready() is True
mock_run.assert_called_once_with(["docker", "info"], capture_output=True, check=False) 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") @patch("devx.molecule.start_docker.subprocess.run")
def test_not_ready(self, mock_run: MagicMock) -> None: def test_not_ready(self, mock_run: MagicMock) -> None:
@@ -20,70 +28,138 @@ class TestIsDockerReady:
assert is_docker_ready() is False assert is_docker_ready() 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)
class TestStartDockerDaemon: class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket")
@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."""
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.time.sleep") @patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready") @patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open) @patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_starts_successfully( def test_starts_local_daemon(
self, self,
mock_file: MagicMock, mock_ntf: MagicMock,
mock_popen: MagicMock, mock_popen: MagicMock,
mock_ready: MagicMock, mock_ready: MagicMock,
mock_sleep: MagicMock, mock_sleep: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
# First loop iteration: dockerd not ready yet. Second: ready. mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
mock_ready.side_effect = [False, True] mock_ready.side_effect = [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
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.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@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_ready: MagicMock,
mock_sleep: MagicMock,
mock_diag: 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._diagnose_socket")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@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_ready: MagicMock,
mock_sleep: MagicMock,
mock_diag: 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._diagnose_socket")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready")
@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_ready: MagicMock,
mock_sleep: MagicMock,
mock_diag: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
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() mock_popen.assert_called_once()
mock_sleep.assert_called_once_with(1) mock_sleep.assert_called_once_with(1)
@patch("devx.molecule.start_docker.time.sleep") @patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False) @patch("devx.molecule.start_docker.os.environ")
@patch("devx.molecule.start_docker.subprocess.Popen") @patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
@patch("builtins.open", new_callable=mock_open) def test_sets_docker_host(
def test_fails_after_timeout(
self, self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock, mock_ready: MagicMock,
mock_sleep: MagicMock, mock_environ: MagicMock,
mock_diag: MagicMock,
) -> None: ) -> None:
assert start_docker_daemon(timeout=3) is False """DOCKER_HOST must be set so molecule connects to correct socket."""
mock_popen.assert_called_once() start_docker_daemon(timeout=5)
assert mock_sleep.call_count == 3 mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_ready_on_first_check(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
) -> None:
mock_ready.return_value = True
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
mock_sleep.assert_not_called()
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_custom_timeout(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
) -> None:
# 9 iterations not ready, 10th ready.
mock_ready.side_effect = [False] * 9 + [True]
assert start_docker_daemon(timeout=10) is True
assert mock_sleep.call_count == 9
class TestMain: class TestMain: