Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb |
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.9.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
|
## [0.9.7] - 2026-06-24
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.9.7"
|
__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:
|
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
|
The branch name is the primary source of truth for the task ID
|
||||||
(e.g., ``DEVX-60``). If the file doesn't exist, extract from the
|
(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file
|
||||||
branch name as a backwards-compatibility fallback.
|
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)
|
path = Path(TASKID_FILE)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
task_id = path.read_text(encoding="utf-8").strip()
|
task_id = path.read_text(encoding="utf-8").strip()
|
||||||
if task_id:
|
if task_id:
|
||||||
return task_id
|
return task_id
|
||||||
# Fallback: extract from branch name
|
return ""
|
||||||
match = TASK_ID_RE.search(branch)
|
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_task_id(branch: str) -> str:
|
def extract_task_id(branch: str) -> str:
|
||||||
|
|||||||
+13
-6
@@ -136,10 +136,14 @@ def verify_tag_consistency() -> list[str]:
|
|||||||
"""
|
"""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
tags = get_all_tags()
|
tags = get_all_tags()
|
||||||
# Sort oldest first to identify the first tag
|
# Filter to version tags (vX.Y.Z) and sort oldest first
|
||||||
sorted_tags = sorted(tags, key=lambda t: [int(x) for x in t.lstrip("v").split(".")])
|
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
|
first_tag = sorted_tags[0] if sorted_tags else None
|
||||||
for tag in tags:
|
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")
|
tag_version = tag.lstrip("v")
|
||||||
commit_version = get_commit_version(tag)
|
commit_version = get_commit_version(tag)
|
||||||
if commit_version is None:
|
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))
|
click.echo(_("Tag {tag} already exists and points to HEAD. Skipping creation.", tag=tag))
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# Ensure the existing tag is pushed
|
# 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
|
return False
|
||||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||||
if dry_run:
|
if dry_run:
|
||||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
||||||
return True
|
return True
|
||||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -478,7 +482,7 @@ def verify_alignment() -> int:
|
|||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
all_release_commits = result.stdout.strip().split("\n")
|
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] = []
|
truly_untagged: list[str] = []
|
||||||
duplicates: list[str] = []
|
duplicates: list[str] = []
|
||||||
for line in all_release_commits:
|
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)
|
# 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()
|
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:
|
if branch != "master" and not dry_run:
|
||||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||||
if branch != "master" and dry_run:
|
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
|
# Pull --rebase before push to handle the case where master
|
||||||
# advanced between checkout and commit (e.g., another merge).
|
# advanced between checkout and commit (e.g., another merge).
|
||||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
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."))
|
click.echo(_("Pushed release commit to master."))
|
||||||
else:
|
else:
|
||||||
click.echo(_("Skipping commit push — no staged changes."))
|
click.echo(_("Skipping commit push — no staged changes."))
|
||||||
|
|||||||
@@ -321,6 +321,17 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
|||||||
|
|
||||||
click.echo(_("PASSED: {pair}", pair=pair))
|
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."))
|
click.echo(_("All molecule tests passed."))
|
||||||
if junit_output:
|
if junit_output:
|
||||||
write_junit_report(junit_output, testcases, current_index)
|
write_junit_report(junit_output, testcases, current_index)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
import sys
|
||||||
@@ -35,11 +36,12 @@ ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
|
|||||||
|
|
||||||
def is_docker_ready() -> bool:
|
def is_docker_ready() -> bool:
|
||||||
"""Check if Docker daemon is responding on the configured socket."""
|
"""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
|
result = subprocess.run( # nosec B603 B607
|
||||||
["docker", "info"],
|
["docker", "info"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
check=False,
|
check=False,
|
||||||
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"},
|
env={**os.environ, "DOCKER_HOST": docker_host},
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
@@ -123,8 +125,21 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
|||||||
click.echo(_("Docker daemon already running"))
|
click.echo(_("Docker daemon already running"))
|
||||||
return True
|
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..."))
|
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)
|
# Start local dockerd (requires privileged container)
|
||||||
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
||||||
mode="w", suffix="dockerd.log", delete=False
|
mode="w", suffix="dockerd.log", delete=False
|
||||||
|
|||||||
@@ -21,9 +21,16 @@ from devx.exceptions import APIError
|
|||||||
|
|
||||||
|
|
||||||
class TestReadTaskid:
|
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)
|
monkeypatch.chdir(tmp_path)
|
||||||
(tmp_path / ".taskid").write_text("DEVX-60\n")
|
(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"
|
assert read_taskid("some-branch") == "DEVX-60"
|
||||||
|
|
||||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
|||||||
@@ -162,17 +162,26 @@ class TestCli:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
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"),
|
patch("time.sleep"),
|
||||||
):
|
):
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
proc.poll.return_value = 0
|
proc.poll.return_value = 0
|
||||||
proc.returncode = 0
|
proc.returncode = 0
|
||||||
mock_popen.return_value = proc
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "All molecule tests passed" in result.output
|
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:
|
def test_invalid_pair_format_raises(self) -> None:
|
||||||
"""Pair with fewer than 2 parts should raise."""
|
"""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.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
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("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||||
):
|
):
|
||||||
@@ -332,6 +342,7 @@ class TestCli:
|
|||||||
proc.poll.return_value = 0
|
proc.poll.return_value = 0
|
||||||
proc.returncode = 0
|
proc.returncode = 0
|
||||||
mock_popen.return_value = proc
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
||||||
@@ -536,12 +547,14 @@ class TestCliMultiRole:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
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"),
|
patch("time.sleep"),
|
||||||
):
|
):
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
proc.poll.return_value = 0
|
proc.poll.return_value = 0
|
||||||
proc.returncode = 0
|
proc.returncode = 0
|
||||||
mock_popen.return_value = proc
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -560,12 +573,14 @@ class TestCliMultiRole:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
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"),
|
patch("time.sleep"),
|
||||||
):
|
):
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
proc.poll.return_value = 0
|
proc.poll.return_value = 0
|
||||||
proc.returncode = 0
|
proc.returncode = 0
|
||||||
mock_popen.return_value = proc
|
mock_popen.return_value = proc
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
|
|||||||
@@ -270,6 +270,15 @@ class TestVerifyTagConsistency:
|
|||||||
mock_tags.return_value = []
|
mock_tags.return_value = []
|
||||||
assert verify_tag_consistency() == []
|
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:
|
class TestGetInitVersion:
|
||||||
def test_returns_version(self, tmp_path, monkeypatch) -> None:
|
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)
|
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
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", "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.tag_exists", return_value=False)
|
||||||
@patch("devx.ci.release.run_cmd")
|
@patch("devx.ci.release.run_cmd")
|
||||||
@@ -803,7 +812,7 @@ class TestCreateAndPushTag:
|
|||||||
# Should not create tag, but should ensure it's pushed
|
# Should not create tag, but should ensure it's pushed
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
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", "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_head_commit", return_value="def456")
|
||||||
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
@patch("devx.ci.release.get_tag_commit", return_value="abc123")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for devx.molecule.start_docker."""
|
"""Unit tests for devx.molecule.start_docker."""
|
||||||
|
|
||||||
|
import os
|
||||||
from unittest.mock import MagicMock, mock_open, patch
|
from unittest.mock import MagicMock, mock_open, patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
@@ -17,7 +18,8 @@ class TestIsDockerReady:
|
|||||||
@patch("devx.molecule.start_docker.subprocess.run")
|
@patch("devx.molecule.start_docker.subprocess.run")
|
||||||
def test_ready(self, mock_run: MagicMock) -> None:
|
def test_ready(self, mock_run: MagicMock) -> None:
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
assert is_docker_ready() is True
|
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
|
call_kwargs = mock_run.call_args
|
||||||
assert call_kwargs.args[0] == ["docker", "info"]
|
assert call_kwargs.args[0] == ["docker", "info"]
|
||||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
|
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
|
||||||
@@ -27,6 +29,16 @@ class TestIsDockerReady:
|
|||||||
mock_run.return_value = MagicMock(returncode=1)
|
mock_run.return_value = MagicMock(returncode=1)
|
||||||
assert is_docker_ready() is False
|
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:
|
class TestDiagnoseSocket:
|
||||||
@patch("devx.molecule.start_docker.os.stat")
|
@patch("devx.molecule.start_docker.os.stat")
|
||||||
@@ -76,8 +88,44 @@ class TestStartDockerDaemon:
|
|||||||
"""Should use rootless socket if host socket fails."""
|
"""Should use rootless socket if host socket fails."""
|
||||||
# First check (host) fails, second check (rootless) succeeds
|
# First check (host) fails, second check (rootless) succeeds
|
||||||
mock_ready.side_effect = [False, True]
|
mock_ready.side_effect = [False, True]
|
||||||
assert start_docker_daemon(timeout=5) is 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._diagnose_socket")
|
||||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
@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.is_docker_ready", return_value=False)
|
||||||
@@ -92,6 +140,7 @@ class TestStartDockerDaemon:
|
|||||||
mock_ready: MagicMock,
|
mock_ready: MagicMock,
|
||||||
mock_exists: MagicMock,
|
mock_exists: MagicMock,
|
||||||
mock_diag: MagicMock,
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
# Host fails, rootless doesn't exist, local daemon starts
|
# Host fails, rootless doesn't exist, local daemon starts
|
||||||
@@ -104,6 +153,7 @@ class TestStartDockerDaemon:
|
|||||||
assert "vfs" in popen_args
|
assert "vfs" in popen_args
|
||||||
assert "-H" 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._diagnose_socket")
|
||||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
@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.is_docker_ready", return_value=False)
|
||||||
@@ -118,6 +168,7 @@ class TestStartDockerDaemon:
|
|||||||
mock_ready: MagicMock,
|
mock_ready: MagicMock,
|
||||||
mock_exists: MagicMock,
|
mock_exists: MagicMock,
|
||||||
mock_diag: MagicMock,
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
with patch("builtins.open", mock_open(read_data="dockerd error log")):
|
with patch("builtins.open", mock_open(read_data="dockerd error log")):
|
||||||
@@ -125,6 +176,7 @@ class TestStartDockerDaemon:
|
|||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
assert mock_sleep.call_count == 3
|
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._diagnose_socket")
|
||||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
@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.is_docker_ready", return_value=False)
|
||||||
@@ -139,12 +191,14 @@ class TestStartDockerDaemon:
|
|||||||
mock_ready: MagicMock,
|
mock_ready: MagicMock,
|
||||||
mock_exists: MagicMock,
|
mock_exists: MagicMock,
|
||||||
mock_diag: MagicMock,
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Should handle log read errors gracefully."""
|
"""Should handle log read errors gracefully."""
|
||||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
with patch("builtins.open", side_effect=OSError("permission denied")):
|
with patch("builtins.open", side_effect=OSError("permission denied")):
|
||||||
assert start_docker_daemon(timeout=2) is False
|
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._diagnose_socket")
|
||||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||||
@@ -159,6 +213,7 @@ class TestStartDockerDaemon:
|
|||||||
mock_ready: MagicMock,
|
mock_ready: MagicMock,
|
||||||
mock_exists: MagicMock,
|
mock_exists: MagicMock,
|
||||||
mock_diag: MagicMock,
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
# Host fails, rootless doesn't exist, local ready on first loop check
|
# Host fails, rootless doesn't exist, local ready on first loop check
|
||||||
|
|||||||
Reference in New Issue
Block a user