Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
037d7b0d16 | ||
|
|
9cb706e387 | ||
|
|
5bd6158f2a | ||
|
|
05aa2ffe76 | ||
|
|
b9c3b55680 | ||
|
|
d398c8e971 | ||
|
|
39526d8e6a | ||
|
|
37730e2187 | ||
|
|
e2f66ca70a | ||
|
|
0b88c211f1 | ||
|
|
ea4ee0d303 | ||
|
|
16fba17b03 | ||
|
|
011cf3e093 | ||
|
|
daf99c5fed | ||
|
|
7154e3ad7c | ||
|
|
6053fb9fba | ||
|
|
e76741bfad | ||
|
|
f206a9cd8d | ||
|
|
b4b7428f9c | ||
|
|
0d9e76a838 | ||
|
|
034cbde2f7 | ||
|
|
e0abe6f176 | ||
|
|
15f6837dc2 | ||
|
|
b4dda91e24 | ||
|
|
3e21e774f7 | ||
|
|
7c11215e57 |
@@ -27,7 +27,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 60 --max-single-seconds 2.0
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -2,6 +2,75 @@
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use vfs storage driver for Docker-in-Docker in CI
|
||||
|
||||
## [0.9.1] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Always start dockerd in CI runner for molecule tests
|
||||
|
||||
## [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
|
||||
|
||||
- Lower check_test_speed threshold to 4 seconds
|
||||
- Pass --break-system-packages to pip in CI environments
|
||||
|
||||
## [0.8.2] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
# Aligned with CI (ci.yml uses same thresholds).
|
||||
set -e
|
||||
export PYTHONPATH=src
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.8.2"
|
||||
__version__ = "0.9.7"
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure Docker is available for molecule tests in CI.
|
||||
|
||||
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
|
||||
Docker socket mounted. This module verifies Docker is accessible and
|
||||
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
|
||||
connects to the same socket as the Docker CLI.
|
||||
|
||||
If the host socket is not available, it tries the rootless socket, then
|
||||
starts a local ``dockerd`` with the vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.molecule.start_docker [--timeout 30]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
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:
|
||||
"""Check if Docker daemon is responding on the configured socket."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"},
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _diagnose_socket() -> None:
|
||||
"""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()}")
|
||||
|
||||
|
||||
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Ensure Docker is ready for molecule tests.
|
||||
|
||||
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
|
||||
returns immediately. If not, tries the rootless socket. If neither
|
||||
works, starts a local ``dockerd`` with vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||
start within the timeout.
|
||||
"""
|
||||
# Point Docker CLI and Python library to the socket explicitly
|
||||
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
|
||||
|
||||
# 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..."))
|
||||
|
||||
# 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
|
||||
[
|
||||
"dockerd",
|
||||
"--storage-driver",
|
||||
"vfs",
|
||||
"-H",
|
||||
f"unix://{DOCKER_SOCK}",
|
||||
],
|
||||
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)
|
||||
|
||||
# Print dockerd log on failure
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
# 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(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
+18
-3
@@ -26,9 +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")
|
||||
_run([pip, "install", "-e", f".[{extras}]"])
|
||||
cmd = [pip, "install", "-e", f".[{extras}]"]
|
||||
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
|
||||
cmd.append("--break-system-packages")
|
||||
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:
|
||||
@@ -40,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
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for devx.tools.setup."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -32,20 +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.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]"], check=False)
|
||||
|
||||
@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"], check=False
|
||||
)
|
||||
|
||||
@patch("devx.tools.setup._run")
|
||||
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
|
||||
_install_python_deps(".venv/bin", "ci,lint")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"])
|
||||
@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:
|
||||
@@ -60,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,223 @@
|
||||
"""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 (
|
||||
DOCKER_SOCK,
|
||||
_diagnose_socket,
|
||||
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
|
||||
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")
|
||||
def test_not_ready(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
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:
|
||||
@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.os.path.exists", return_value=True)
|
||||
@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.tempfile.NamedTemporaryFile")
|
||||
def test_starts_local_daemon(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# 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
|
||||
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
|
||||
|
||||
@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.tempfile.NamedTemporaryFile")
|
||||
def test_fails_after_timeout(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: 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.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.tempfile.NamedTemporaryFile")
|
||||
def test_fails_log_read_error(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: 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.os.path.exists", return_value=False)
|
||||
@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.tempfile.NamedTemporaryFile")
|
||||
def test_local_daemon_ready_on_first_check(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
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]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert mock_popen.call_count == 1
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker.os.environ")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
def test_sets_docker_host(
|
||||
self,
|
||||
mock_ready: MagicMock,
|
||||
mock_environ: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""DOCKER_HOST must be set so molecule connects to correct socket."""
|
||||
start_docker_daemon(timeout=5)
|
||||
mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user