Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f206a9cd8d | ||
|
|
b4b7428f9c | ||
|
|
0d9e76a838 | ||
|
|
034cbde2f7 | ||
|
|
e0abe6f176 | ||
|
|
15f6837dc2 |
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.9.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Extract Docker daemon start to tested Python module
|
||||
|
||||
## [0.8.5] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry pip install with --ignore-installed only on failure
|
||||
|
||||
## [0.8.4] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add --ignore-installed to pip in CI to bypass debian packages
|
||||
|
||||
## [0.8.3] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.8.3"
|
||||
__version__ = "0.9.0"
|
||||
|
||||
@@ -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()
|
||||
+15
-5
@@ -26,14 +26,24 @@ def _run(cmd: list[str]) -> None:
|
||||
|
||||
|
||||
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
|
||||
"""Install the project with the specified extras in editable mode."""
|
||||
"""Install the project with the specified extras in editable mode.
|
||||
|
||||
In CI (system Python with PIP_BREAK_SYSTEM_PACKAGES=1), a first attempt
|
||||
uses --break-system-packages. If that fails (e.g. debian-installed
|
||||
packages without RECORD files), retry with --ignore-installed to skip
|
||||
uninstalling system packages entirely.
|
||||
"""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
cmd = [pip, "install", "-e", f".[{extras}]"]
|
||||
# In CI (system Python), --break-system-packages allows upgrading
|
||||
# debian-installed packages (e.g. platformdirs) that lack RECORD files.
|
||||
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
|
||||
cmd.append("--break-system-packages")
|
||||
_run(cmd)
|
||||
result = subprocess.run(cmd, check=False) # nosec B603
|
||||
if result.returncode != 0 and os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
|
||||
click.echo(" Retrying with --ignore-installed to bypass system packages...")
|
||||
cmd.append("--ignore-installed")
|
||||
_run(cmd)
|
||||
elif result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, cmd)
|
||||
|
||||
|
||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
@@ -45,7 +55,7 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
|
||||
def _install_ansible_collections(bin_dir: str) -> None:
|
||||
"""Install required Ansible Galaxy collections if requirements exist."""
|
||||
galaxy = str(Path(bin_dir) / "ansible-galaxy")
|
||||
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
|
||||
+728
-700
File diff suppressed because it is too large
Load Diff
@@ -33,26 +33,48 @@ class TestRun:
|
||||
|
||||
|
||||
class TestInstallPythonDeps:
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_dev(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "dev")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"])
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_ci(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"])
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
_install_python_deps(".venv/bin", "ci,lint")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"])
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_with_break_system_packages(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages"])
|
||||
mock_run.assert_called_once_with(
|
||||
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages"], check=False
|
||||
)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_retry_with_ignore_installed(self, mock_subprocess: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
mock_run.assert_called_once_with(
|
||||
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages", "--ignore-installed"]
|
||||
)
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_install_python_deps(".venv/bin", "ci")
|
||||
|
||||
|
||||
class TestInstallPreCommitHooks:
|
||||
@@ -67,8 +89,9 @@ class TestInstallPreCommitHooks:
|
||||
|
||||
|
||||
class TestInstallAnsibleCollections:
|
||||
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
def test_installs_from_requirements(self, mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path) -> None:
|
||||
req = tmp_path / "ansible" / "requirements.yml"
|
||||
req.parent.mkdir(parents=True)
|
||||
req.write_text("collections: []")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user