DEVX-20: feat: extract Docker daemon start to tested Python module

This commit is contained in:
emil
2026-06-23 23:28:20 +00:00
parent 0d9e76a838
commit b4b7428f9c
4 changed files with 910 additions and 701 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-19
DEVX-20
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Start a Docker daemon inside a CI runner container (Docker-in-Docker).
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may not have a
Docker daemon running. This module starts ``dockerd`` in the background
and waits for it to become ready, or exits immediately if Docker is
already available.
Usage::
python3 -m devx.molecule.start_docker [--timeout 30]
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
import time
import click
from devx.i18n import _
DEFAULT_TIMEOUT = 30
DOCKERD_LOG = "/var/log/dockerd.log"
def is_docker_ready() -> bool:
"""Check if the Docker daemon is responding."""
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
)
return result.returncode == 0
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Start dockerd in the background and wait for it to be ready.
Returns ``True`` if Docker is ready (either already running or
successfully started), ``False`` if it failed to start within
the timeout.
"""
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Starting Docker daemon..."))
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115
subprocess.Popen( # nosec B603 B607
["dockerd"],
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
for _i in range(timeout):
if is_docker_ready():
click.echo(_("Docker daemon started"))
return True
time.sleep(1)
click.echo(_("Docker daemon failed to start"))
return False
@click.command()
@click.option(
"--timeout",
default=DEFAULT_TIMEOUT,
type=int,
help="Seconds to wait for Docker daemon to start (default: 30).",
)
def main(timeout: int) -> None:
"""Start Docker daemon for CI molecule tests."""
if start_docker_daemon(timeout):
sys.exit(0)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+728 -700
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""Unit tests for devx.molecule.start_docker."""
from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner
from devx.molecule.start_docker import is_docker_ready, main, start_docker_daemon
class TestIsDockerReady:
@patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
assert is_docker_ready() is True
mock_run.assert_called_once_with(["docker", "info"], capture_output=True, check=False)
@patch("devx.molecule.start_docker.subprocess.run")
def test_not_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1)
assert is_docker_ready() is False
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_already_running(self, mock_ready: MagicMock) -> None:
assert start_docker_daemon() is True
mock_ready.assert_called_once()
@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_starts_successfully(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
) -> None:
# First call: initial check (not ready). Second: first loop iteration (ready).
mock_ready.side_effect = [False, 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", return_value=False)
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_fails_after_timeout(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
) -> None:
# is_docker_ready always returns False: 1 initial + 3 loop iterations = 4 calls
assert start_docker_daemon(timeout=3) is False
mock_popen.assert_called_once()
assert mock_sleep.call_count == 3
@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:
# First call: initial check (not ready). Then 9 loop iterations (not ready),
# 10th iteration (ready).
mock_ready.side_effect = [False] * 10 + [True]
assert start_docker_daemon(timeout=10) is True
assert mock_sleep.call_count == 9
class TestMain:
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_success(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=False)
def test_failure(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 1
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_custom_timeout_flag(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--timeout", "60"])
assert result.exit_code == 0
mock_start.assert_called_once_with(60)