Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb | ||
|
|
037d7b0d16 | ||
|
|
9cb706e387 | ||
|
|
5bd6158f2a | ||
|
|
05aa2ffe76 | ||
|
|
b9c3b55680 | ||
|
|
d398c8e971 | ||
|
|
39526d8e6a | ||
|
|
37730e2187 | ||
|
|
e2f66ca70a | ||
|
|
0b88c211f1 | ||
|
|
ea4ee0d303 | ||
|
|
16fba17b03 |
@@ -2,6 +2,53 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
### 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
|
## [0.9.2] - 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.2"
|
__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)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Start a Docker daemon inside a CI runner container (Docker-in-Docker).
|
"""Ensure Docker is available for molecule tests in CI.
|
||||||
|
|
||||||
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
|
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
|
||||||
Docker socket mounted, but molecule needs a local Docker daemon to create
|
Docker socket mounted. This module verifies Docker is accessible and
|
||||||
nested containers. This module always starts ``dockerd`` in the background
|
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
|
||||||
and waits for it to become ready.
|
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::
|
Usage::
|
||||||
|
|
||||||
@@ -13,8 +17,11 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -22,33 +29,130 @@ import click
|
|||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
DOCKERD_LOG = "/var/log/dockerd.log"
|
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:
|
def is_docker_ready() -> bool:
|
||||||
"""Check if the Docker daemon is responding."""
|
"""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": docker_host},
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
def _diagnose_socket() -> None:
|
||||||
"""Start dockerd in the background and wait for it to be ready.
|
"""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()}")
|
||||||
|
|
||||||
Always starts a local dockerd even if ``docker info`` succeeds,
|
|
||||||
because the host socket may be mounted but not suitable for
|
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||||
molecule's nested container creation.
|
"""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
|
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||||
start within the timeout.
|
start within the timeout.
|
||||||
"""
|
"""
|
||||||
click.echo(_("Starting Docker daemon..."))
|
# Point Docker CLI and Python library to the socket explicitly
|
||||||
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115
|
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
|
subprocess.Popen( # nosec B603 B607
|
||||||
["dockerd", "--storage-driver", "vfs"],
|
[
|
||||||
|
"dockerd",
|
||||||
|
"--storage-driver",
|
||||||
|
"vfs",
|
||||||
|
"-H",
|
||||||
|
f"unix://{DOCKER_SOCK}",
|
||||||
|
],
|
||||||
stdout=log_file,
|
stdout=log_file,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
@@ -60,7 +164,17 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
|||||||
return True
|
return True
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Print dockerd log on failure
|
||||||
click.echo(_("Docker daemon failed to start"))
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +188,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
|||||||
def main(timeout: int) -> None:
|
def main(timeout: int) -> None:
|
||||||
"""Start Docker daemon for CI molecule tests."""
|
"""Start Docker daemon for CI molecule tests."""
|
||||||
if start_docker_daemon(timeout):
|
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(0)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -419,6 +419,13 @@
|
|||||||
"ru": "Created release commit.",
|
"ru": "Created release commit.",
|
||||||
"zh": "Created release commit."
|
"zh": "Created release commit."
|
||||||
},
|
},
|
||||||
|
"Docker daemon already running": {
|
||||||
|
"bg": "Докер демонът вече работи",
|
||||||
|
"de": "Docker-Daemon läuft bereits",
|
||||||
|
"en": "Docker daemon already running",
|
||||||
|
"ru": "Демон Docker уже работает",
|
||||||
|
"zh": "Docker 守护进程已在运行"
|
||||||
|
},
|
||||||
"Docker daemon failed to start": {
|
"Docker daemon failed to start": {
|
||||||
"bg": "Docker daemon failed to start",
|
"bg": "Docker daemon failed to start",
|
||||||
"de": "Docker-Daemon konnte nicht gestartet werden",
|
"de": "Docker-Daemon konnte nicht gestartet werden",
|
||||||
@@ -552,6 +559,13 @@
|
|||||||
"ru": "Head branch is behind master. Pulling and rebasing...",
|
"ru": "Head branch is behind master. Pulling and rebasing...",
|
||||||
"zh": "Head branch is behind master. Pulling and rebasing..."
|
"zh": "Head branch is behind master. Pulling and rebasing..."
|
||||||
},
|
},
|
||||||
|
"Host Docker not available, starting local dockerd...": {
|
||||||
|
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
|
||||||
|
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
|
||||||
|
"en": "Host Docker not available, starting local dockerd...",
|
||||||
|
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
||||||
|
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
||||||
|
},
|
||||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||||
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
||||||
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
||||||
@@ -930,13 +944,6 @@
|
|||||||
"ru": "Skipping commit push — no staged changes.",
|
"ru": "Skipping commit push — no staged changes.",
|
||||||
"zh": "Skipping commit push — no staged changes."
|
"zh": "Skipping commit push — no staged changes."
|
||||||
},
|
},
|
||||||
"Starting Docker daemon...": {
|
|
||||||
"bg": "Starting Docker daemon...",
|
|
||||||
"de": "Docker-Daemon wird gestartet...",
|
|
||||||
"en": "Starting Docker daemon...",
|
|
||||||
"ru": "Запуск Docker-демона...",
|
|
||||||
"zh": "正在启动 Docker 守护进程..."
|
|
||||||
},
|
|
||||||
"Syncing {count} documentation pages to wiki...": {
|
"Syncing {count} documentation pages to wiki...": {
|
||||||
"bg": "Syncing {count} documentation pages to wiki...",
|
"bg": "Syncing {count} documentation pages to wiki...",
|
||||||
"de": "Syncing {count} documentation pages to wiki...",
|
"de": "Syncing {count} documentation pages to wiki...",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+208
-37
@@ -1,89 +1,239 @@
|
|||||||
"""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
|
||||||
|
|
||||||
from devx.molecule.start_docker import is_docker_ready, main, start_docker_daemon
|
from devx.molecule.start_docker import (
|
||||||
|
DOCKER_SOCK,
|
||||||
|
_diagnose_socket,
|
||||||
|
is_docker_ready,
|
||||||
|
main,
|
||||||
|
start_docker_daemon,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestIsDockerReady:
|
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):
|
||||||
mock_run.assert_called_once_with(["docker", "info"], capture_output=True, check=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")
|
@patch("devx.molecule.start_docker.subprocess.run")
|
||||||
def test_not_ready(self, mock_run: MagicMock) -> None:
|
def test_not_ready(self, mock_run: MagicMock) -> None:
|
||||||
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:
|
||||||
|
@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:
|
class TestStartDockerDaemon:
|
||||||
@patch("devx.molecule.start_docker.time.sleep")
|
@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")
|
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
def test_rootless_socket_available(
|
||||||
@patch("builtins.open", new_callable=mock_open)
|
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||||
def test_starts_successfully(
|
|
||||||
self,
|
|
||||||
mock_file: MagicMock,
|
|
||||||
mock_popen: MagicMock,
|
|
||||||
mock_ready: MagicMock,
|
|
||||||
mock_sleep: MagicMock,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# First loop iteration: dockerd not ready yet. Second: ready.
|
"""Should use rootless socket if host socket fails."""
|
||||||
|
# First check (host) fails, second check (rootless) succeeds
|
||||||
mock_ready.side_effect = [False, True]
|
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
|
assert start_docker_daemon(timeout=5) is True
|
||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
mock_sleep.assert_called_once_with(1)
|
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.time.sleep")
|
@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.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.subprocess.Popen")
|
||||||
@patch("builtins.open", new_callable=mock_open)
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||||
def test_fails_after_timeout(
|
def test_fails_after_timeout(
|
||||||
self,
|
self,
|
||||||
mock_file: MagicMock,
|
mock_ntf: MagicMock,
|
||||||
mock_popen: MagicMock,
|
mock_popen: MagicMock,
|
||||||
mock_ready: MagicMock,
|
|
||||||
mock_sleep: MagicMock,
|
mock_sleep: MagicMock,
|
||||||
|
mock_ready: MagicMock,
|
||||||
|
mock_exists: MagicMock,
|
||||||
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
assert start_docker_daemon(timeout=3) is False
|
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()
|
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.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.time.sleep")
|
||||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
|
||||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||||
@patch("builtins.open", new_callable=mock_open)
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||||
def test_ready_on_first_check(
|
def test_fails_log_read_error(
|
||||||
self,
|
self,
|
||||||
mock_file: MagicMock,
|
mock_ntf: MagicMock,
|
||||||
mock_popen: MagicMock,
|
mock_popen: MagicMock,
|
||||||
mock_ready: MagicMock,
|
|
||||||
mock_sleep: MagicMock,
|
mock_sleep: MagicMock,
|
||||||
|
mock_ready: MagicMock,
|
||||||
|
mock_exists: MagicMock,
|
||||||
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_ready.return_value = True
|
"""Should handle log read errors gracefully."""
|
||||||
assert start_docker_daemon(timeout=5) is True
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
mock_popen.assert_called_once()
|
with patch("builtins.open", side_effect=OSError("permission denied")):
|
||||||
mock_sleep.assert_not_called()
|
assert start_docker_daemon(timeout=2) is False
|
||||||
|
|
||||||
@patch("devx.molecule.start_docker.time.sleep")
|
@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.is_docker_ready")
|
||||||
|
@patch("devx.molecule.start_docker.time.sleep")
|
||||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||||
@patch("builtins.open", new_callable=mock_open)
|
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||||
def test_custom_timeout(
|
def test_local_daemon_ready_on_first_check(
|
||||||
self,
|
self,
|
||||||
mock_file: MagicMock,
|
mock_ntf: MagicMock,
|
||||||
mock_popen: MagicMock,
|
mock_popen: MagicMock,
|
||||||
mock_ready: MagicMock,
|
|
||||||
mock_sleep: MagicMock,
|
mock_sleep: MagicMock,
|
||||||
|
mock_ready: MagicMock,
|
||||||
|
mock_exists: MagicMock,
|
||||||
|
mock_diag: MagicMock,
|
||||||
|
mock_glob: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
# 9 iterations not ready, 10th ready.
|
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||||
mock_ready.side_effect = [False] * 9 + [True]
|
# Host fails, rootless doesn't exist, local ready on first loop check
|
||||||
assert start_docker_daemon(timeout=10) is True
|
mock_ready.side_effect = [False, False, True]
|
||||||
assert mock_sleep.call_count == 9
|
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:
|
class TestMain:
|
||||||
@@ -105,3 +255,24 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--timeout", "60"])
|
result = runner.invoke(main, ["--timeout", "60"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_start.assert_called_once_with(60)
|
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