Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb | ||
|
|
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 | ||
|
|
5384269c83 | ||
|
|
b3d0dd8ca7 | ||
|
|
a7dcaee5c6 | ||
|
|
02f8d3757b | ||
|
|
4311fb7648 | ||
|
|
2ead959fcf |
@@ -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
|
||||
|
||||
+102
@@ -2,6 +2,108 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.9.9] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
- Prefer branch name for task ID extraction + strip heads/ prefix in release
|
||||
- Filter non-version tags in release verification
|
||||
- Use explicit refspecs for git push to avoid tag/branch ambiguity
|
||||
|
||||
## [0.9.8] - 2026-06-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
|
||||
|
||||
## [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
|
||||
|
||||
- Encode spaces in pair commands to survive shell word-splitting
|
||||
|
||||
## [0.8.1] - 2026-06-23
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set fresh MOLECULE_HOME per pair to avoid stale config cache
|
||||
|
||||
## [0.8.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- Fix molecule platforms to use sleep infinity, add --platforms-file
|
||||
|
||||
## [0.7.0] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
+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.7.0"
|
||||
__version__ = "0.9.9"
|
||||
|
||||
@@ -63,20 +63,22 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from .taskid file, falling back to branch name extraction.
|
||||
"""Read task ID from branch name, falling back to .taskid file.
|
||||
|
||||
The .taskid file is a simple text file containing just the task ID
|
||||
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
|
||||
branch name as a backwards-compatibility fallback.
|
||||
The branch name is the primary source of truth for the task ID
|
||||
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
||||
is a legacy fallback for branches without a task ID prefix.
|
||||
"""
|
||||
branch_task_id = extract_task_id(branch)
|
||||
if branch_task_id:
|
||||
return branch_task_id
|
||||
# Fallback: read from .taskid file
|
||||
path = Path(TASKID_FILE)
|
||||
if path.exists():
|
||||
task_id = path.read_text(encoding="utf-8").strip()
|
||||
if task_id:
|
||||
return task_id
|
||||
# Fallback: extract from branch name
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
return ""
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
|
||||
+13
-6
@@ -136,10 +136,14 @@ def verify_tag_consistency() -> list[str]:
|
||||
"""
|
||||
errors: list[str] = []
|
||||
tags = get_all_tags()
|
||||
# Sort oldest first to identify the first tag
|
||||
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
# Filter to version tags (vX.Y.Z) and sort oldest first
|
||||
version_tags = [t for t in tags if re.match(r"^v\d+\.\d+\.\d+$", t)]
|
||||
sorted_tags = sorted(version_tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
||||
first_tag = sorted_tags[0] if sorted_tags else None
|
||||
for tag in tags:
|
||||
# Skip non-version tags (e.g., branch names like "master")
|
||||
if not re.match(r"^v\d+\.\d+\.\d+$", tag):
|
||||
continue
|
||||
tag_version = tag.lstrip("v")
|
||||
commit_version = get_commit_version(tag)
|
||||
if commit_version is None:
|
||||
@@ -340,14 +344,14 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", tag], check=False)
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", tag])
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -478,7 +482,7 @@ def verify_alignment() -> int:
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
all_release_commits = result.stdout.strip().split("\n")
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags()}
|
||||
all_tags_set = {t.lstrip("v") for t in get_all_tags() if re.match(r"^v\d+\.\d+\.\d+$", t)}
|
||||
truly_untagged: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
for line in all_release_commits:
|
||||
@@ -547,6 +551,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
# Some git versions return "heads/master" instead of "master"
|
||||
branch = branch.removeprefix("heads/")
|
||||
if branch != "master" and not dry_run:
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
if branch != "master" and dry_run:
|
||||
@@ -694,7 +700,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
@@ -41,7 +41,8 @@ class TestPair:
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
cmd = self.platform["command"].replace(" ", "__SPACE__")
|
||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> TestPair:
|
||||
@@ -49,7 +50,7 @@ class TestPair:
|
||||
parts = encoded.split("|")
|
||||
return TestPair(
|
||||
scenario=parts[0],
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
||||
platform={"name": parts[1], "image": parts[2], "command": parts[3].replace("__SPACE__", " ")},
|
||||
)
|
||||
|
||||
|
||||
@@ -63,9 +64,8 @@ class MultiRoleTestPair:
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
|
||||
return (
|
||||
f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
||||
)
|
||||
cmd = self.platform["command"].replace(" ", "__SPACE__")
|
||||
return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
|
||||
|
||||
@staticmethod
|
||||
def decode(encoded: str) -> MultiRoleTestPair:
|
||||
@@ -74,7 +74,7 @@ class MultiRoleTestPair:
|
||||
return MultiRoleTestPair(
|
||||
role=parts[0],
|
||||
scenario=parts[1],
|
||||
platform={"name": parts[2], "image": parts[3], "command": parts[4]},
|
||||
platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +238,13 @@ def _write_github_env(key: str, value: str) -> None:
|
||||
help="Roles directory for multi-role discovery (scans */molecule/*/). "
|
||||
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).",
|
||||
)
|
||||
@click.option(
|
||||
"--platforms-file",
|
||||
type=click.Path(exists=True, file_okay=True, path_type=Path),
|
||||
default=None,
|
||||
help="JSON file with custom platform list (each entry: name, image, command). "
|
||||
"Overrides the default platform matrix. Useful for projects with custom test images.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
@@ -247,7 +254,9 @@ def cli(
|
||||
skip_if_excess: bool,
|
||||
molecule_root: Path | None,
|
||||
roles_root: Path | None,
|
||||
platforms_file: Path | None,
|
||||
) -> None:
|
||||
platforms = load_platforms(platforms_file)
|
||||
# Multi-role mode: discover (role, scenario) pairs across all roles
|
||||
if roles_root is not None:
|
||||
role_scenarios = discover_multi_role_scenarios(roles_root)
|
||||
@@ -256,10 +265,10 @@ def cli(
|
||||
click.echo(f"{role}|{scenario}")
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
for p in platforms:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs_mr = build_multi_role_pairs(role_scenarios)
|
||||
pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
|
||||
if runner_index is None:
|
||||
groups = distribute_multi_role(pairs_mr, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
@@ -291,10 +300,10 @@ def cli(
|
||||
click.echo(s)
|
||||
return
|
||||
if list_platforms:
|
||||
for p in PLATFORMS:
|
||||
for p in platforms:
|
||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
||||
return
|
||||
pairs = build_pairs(scenarios)
|
||||
pairs = build_pairs(scenarios, platforms)
|
||||
if runner_index is None:
|
||||
groups = distribute(pairs, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
|
||||
@@ -114,12 +114,14 @@ def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
|
||||
|
||||
Supports both 4-part (single-role) and 5-part (multi-role) formats.
|
||||
For 4-part pairs, role is empty (caller uses default role dir).
|
||||
Spaces in the command field are encoded as ``__SPACE__`` to survive
|
||||
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
|
||||
"""
|
||||
parts = pair.split("|")
|
||||
if len(parts) == 4:
|
||||
return "", parts[0], parts[1], parts[2], parts[3]
|
||||
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
|
||||
if len(parts) == 5:
|
||||
return parts[0], parts[1], parts[2], parts[3], parts[4]
|
||||
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
|
||||
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
|
||||
|
||||
|
||||
@@ -134,6 +136,12 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
|
||||
# from previous CI runs (causes "Instances missing" errors).
|
||||
if "MOLECULE_HOME" not in env:
|
||||
import tempfile
|
||||
|
||||
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
|
||||
return env
|
||||
|
||||
|
||||
@@ -313,6 +321,17 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
# Prune Docker data between scenarios to prevent disk exhaustion
|
||||
# in Docker-in-Docker molecule containers (each scenario pulls
|
||||
# hundreds of MB of images that accumulate across pairs).
|
||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
||||
subprocess.run( # nosec B603, B607
|
||||
["docker", "system", "prune", "-af", "--volumes"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
|
||||
@@ -10,13 +10,39 @@ dev tools and CI scripts.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
#: Default supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires
|
||||
#: loginctl/systemctl --user.
|
||||
#: Uses the project's pre-built molecule-test-base image with
|
||||
#: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
{
|
||||
"name": "ubuntu-2604",
|
||||
"image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
|
||||
"command": "sleep infinity",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]:
|
||||
"""Load platforms from a JSON file, falling back to PLATFORMS.
|
||||
|
||||
Args:
|
||||
platforms_file: Path to a JSON file with a list of platform dicts.
|
||||
Each dict must have ``name``, ``image``, and ``command`` keys.
|
||||
|
||||
Returns:
|
||||
List of platform dictionaries.
|
||||
"""
|
||||
if platforms_file is None:
|
||||
return PLATFORMS
|
||||
path = Path(platforms_file)
|
||||
if not path.is_file():
|
||||
return PLATFORMS
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list) or not data:
|
||||
return PLATFORMS
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/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 glob
|
||||
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."""
|
||||
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": docker_host},
|
||||
)
|
||||
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
|
||||
|
||||
# Scan for any rootless sockets at other UIDs
|
||||
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
|
||||
if sock == ROOTLESS_SOCK:
|
||||
continue
|
||||
click.echo(f"Trying alternative rootless socket: {sock}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{sock}"
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
click.echo(_("Host Docker not available, starting local dockerd..."))
|
||||
|
||||
# Reset DOCKER_HOST to host socket for local dockerd
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# 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
@@ -21,9 +21,16 @@ from devx.exceptions import APIError
|
||||
|
||||
|
||||
class TestReadTaskid:
|
||||
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def test_prefers_branch_name_over_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# Branch name takes priority over .taskid file
|
||||
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
|
||||
|
||||
def test_falls_back_to_file_when_no_branch_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
||||
# No task ID in branch name → fall back to .taskid
|
||||
assert read_taskid("some-branch") == "DEVX-60"
|
||||
|
||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -163,10 +163,7 @@ class TestCli:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--list-platforms"])
|
||||
assert result.exit_code == 0
|
||||
assert "ubuntu-2204" in result.output
|
||||
assert "ubuntu-2404" in result.output
|
||||
assert "debian-12" in result.output
|
||||
assert "archlinux" in result.output
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
@@ -198,7 +195,7 @@ class TestCli:
|
||||
assert result.exit_code == 0
|
||||
# Output should contain encoded pairs with platform info
|
||||
assert "alpha|" in result.output
|
||||
assert "ubuntu-2204" in result.output
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
|
||||
class TestGithubEnv:
|
||||
@@ -409,7 +406,7 @@ class TestCliMultiRole:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
|
||||
assert result.exit_code == 0
|
||||
assert "ubuntu-2204" in result.output
|
||||
assert "ubuntu-2604" in result.output
|
||||
|
||||
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
|
||||
"""--roles-root without --runner-index prints all groups."""
|
||||
@@ -422,6 +419,27 @@ class TestCliMultiRole:
|
||||
assert "Runner 0:" in result.output
|
||||
assert "Runner 1:" in result.output
|
||||
|
||||
def test_platforms_file_overrides_default(self, tmp_path: Path) -> None:
|
||||
"""--platforms-file loads custom platforms from JSON."""
|
||||
import json
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.distribute_molecule import cli
|
||||
|
||||
roles = tmp_path / "roles"
|
||||
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
|
||||
platforms_file = tmp_path / "platforms.json"
|
||||
custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}]
|
||||
platforms_file.write_text(json.dumps(custom))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "custom-os" in result.output
|
||||
assert "custom:latest" in result.output
|
||||
|
||||
def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
|
||||
"""Non-directory entries in roles root are skipped."""
|
||||
roles = tmp_path / "roles"
|
||||
|
||||
@@ -162,17 +162,26 @@ class TestCli:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule tests passed" in result.output
|
||||
# Verify Docker prune was called between scenarios
|
||||
mock_run.assert_called_once_with(
|
||||
["docker", "system", "prune", "-af", "--volumes"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
def test_invalid_pair_format_raises(self) -> None:
|
||||
"""Pair with fewer than 2 parts should raise."""
|
||||
@@ -324,6 +333,7 @@ class TestCli:
|
||||
),
|
||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||
):
|
||||
@@ -332,6 +342,7 @@ class TestCli:
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||
@@ -536,12 +547,14 @@ class TestCliMultiRole:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -560,12 +573,14 @@ class TestCliMultiRole:
|
||||
|
||||
with (
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
mock_popen.return_value = proc
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Unit tests for scripts/ci/platforms.py."""
|
||||
"""Unit tests for devx.molecule.platforms."""
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS
|
||||
import json
|
||||
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
|
||||
class TestPlatforms:
|
||||
def test_platforms_not_empty(self) -> None:
|
||||
assert len(PLATFORMS) >= 4
|
||||
assert len(PLATFORMS) >= 1
|
||||
|
||||
def test_each_platform_has_required_keys(self) -> None:
|
||||
for p in PLATFORMS:
|
||||
@@ -17,9 +19,40 @@ class TestPlatforms:
|
||||
names = [p["name"] for p in PLATFORMS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_platforms_use_sleep_infinity(self) -> None:
|
||||
"""All default platforms must use sleep infinity, not systemd."""
|
||||
for p in PLATFORMS:
|
||||
assert p["command"] == "sleep infinity", f"Platform {p['name']} uses {p['command']}"
|
||||
|
||||
def test_known_platforms_present(self) -> None:
|
||||
names = {p["name"] for p in PLATFORMS}
|
||||
assert "ubuntu-2204" in names
|
||||
assert "ubuntu-2404" in names
|
||||
assert "debian-12" in names
|
||||
assert "archlinux" in names
|
||||
assert "ubuntu-2604" in names
|
||||
|
||||
|
||||
class TestLoadPlatforms:
|
||||
def test_load_platforms_default(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms with no file returns PLATFORMS."""
|
||||
result = load_platforms(None)
|
||||
assert result == PLATFORMS
|
||||
|
||||
def test_load_platforms_from_file(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms reads custom platforms from JSON file."""
|
||||
custom = [
|
||||
{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"},
|
||||
]
|
||||
f = tmp_path / "platforms.json"
|
||||
f.write_text(json.dumps(custom))
|
||||
result = load_platforms(f)
|
||||
assert result == custom
|
||||
|
||||
def test_load_platforms_missing_file_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms falls back to PLATFORMS when file doesn't exist."""
|
||||
result = load_platforms(tmp_path / "nonexistent.json")
|
||||
assert result == PLATFORMS
|
||||
|
||||
def test_load_platforms_empty_list_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
"""load_platforms falls back to PLATFORMS when file has empty list."""
|
||||
f = tmp_path / "platforms.json"
|
||||
f.write_text("[]")
|
||||
result = load_platforms(f)
|
||||
assert result == PLATFORMS
|
||||
|
||||
@@ -270,6 +270,15 @@ class TestVerifyTagConsistency:
|
||||
mock_tags.return_value = []
|
||||
assert verify_tag_consistency() == []
|
||||
|
||||
@patch("devx.ci.release.get_commit_version")
|
||||
@patch("devx.ci.release.get_all_tags")
|
||||
def test_non_version_tags_ignored(self, mock_tags: MagicMock, mock_cv: MagicMock) -> None:
|
||||
"""Non-version tags like 'master' should be skipped, not crash."""
|
||||
mock_tags.return_value = ["v0.2.0", "master", "v0.1.0"]
|
||||
mock_cv.side_effect = ["0.2.0", "0.1.0"] # only version tags get checked
|
||||
errors = verify_tag_consistency()
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestGetInitVersion:
|
||||
def test_returns_version(self, tmp_path, monkeypatch) -> None:
|
||||
@@ -776,7 +785,7 @@ class TestCreateAndPushTag:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
||||
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.tag_exists", return_value=False)
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
@@ -803,7 +812,7 @@ class TestCreateAndPushTag:
|
||||
# Should not create tag, but should ensure it's pushed
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
assert ["git", "push", "origin", "refs/tags/v0.1.0"] in calls
|
||||
|
||||
@patch("devx.ci.release.get_head_commit", return_value="def456")
|
||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||
|
||||
@@ -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,278 @@
|
||||
"""Unit tests for devx.molecule.start_docker."""
|
||||
|
||||
import os
|
||||
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)
|
||||
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
|
||||
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
|
||||
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
|
||||
"""Should check the socket specified by DOCKER_HOST env var."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
rootless = "unix:///run/user/999/docker.sock"
|
||||
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
|
||||
assert is_docker_ready() is True
|
||||
call_kwargs = mock_run.call_args
|
||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
|
||||
|
||||
|
||||
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]
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
|
||||
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=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_alt_rootless_socket_found(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should find rootless socket at a different UID via glob scan."""
|
||||
# Host fails, own rootless fails, alt rootless succeeds
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
|
||||
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=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
def test_alt_rootless_socket_skips_own(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should skip the own rootless socket in glob scan (already tried)."""
|
||||
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
|
||||
mock_ready.side_effect = [False, False, False, False, False, False]
|
||||
own_sock = f"/run/user/{os.getuid()}/docker.sock"
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with (
|
||||
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
|
||||
patch("devx.molecule.start_docker.time.sleep"),
|
||||
patch("devx.molecule.start_docker.subprocess.Popen"),
|
||||
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
|
||||
patch("builtins.open", mock_open(read_data="err")),
|
||||
):
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
assert start_docker_daemon(timeout=2) is False
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@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,
|
||||
mock_glob: 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.glob.glob", return_value=[])
|
||||
@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,
|
||||
mock_glob: 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.glob.glob", return_value=[])
|
||||
@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,
|
||||
mock_glob: 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.glob.glob", return_value=[])
|
||||
@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,
|
||||
mock_glob: 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