Compare commits

...
21 Commits
Author SHA1 Message Date
devx-ci-bot 80ca622838 release: v0.9.10 [skip ci] 2026-06-24 18:42:23 +02:00
emil 3f2d19d7ac DEVX-34: fix: retrospective fixes for CI/CD friction
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / release (push) Successful in 50s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 40s
2026-06-24 16:41:22 +00:00
devx-ci-bot c839d49fe3 release: v0.9.9 [skip ci] 2026-06-24 13:17:30 +02:00
emil 93b5d2f926 DEVX-33: fix: use explicit refspecs for git push to avoid tag/branch ambiguity
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 39s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 39s
Post-merge / badges (push) Successful in 41s
2026-06-24 11:16:41 +00:00
emil 131c04c9d0 DEVX-32: fix: filter non-version tags in release verification
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 37s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 40s
2026-06-24 11:09:47 +00:00
emil 8e9681cf7d DEVX-31: fix: prefer branch name for task ID extraction + strip heads/ prefix in release
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 29s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 37s
2026-06-24 10:54:58 +00:00
emil 6631525a1d DEVX-30: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 6s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Failing after 35s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 39s
2026-06-24 10:35:32 +00:00
devx-ci-bot 6985030a3c release: v0.9.8 [skip ci] 2026-06-24 11:15:20 +02:00
emil 4d073f3beb DEVX-29: fix: use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 42s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 41s
2026-06-24 09:14:27 +00:00
devx-ci-bot 037d7b0d16 release: v0.9.7 [skip ci] 2026-06-24 02:18:19 +00:00
emil 9cb706e387 DEVX-28: fix: add rootless socket fallback and GITHUB_ENV export
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 7s
Post-merge / sync-wiki (push) Successful in 41s
Post-merge / badges (push) Successful in 42s
2026-06-24 02:17:20 +00:00
devx-ci-bot 5bd6158f2a release: v0.9.6 [skip ci] 2026-06-24 04:00:50 +02:00
emil 05aa2ffe76 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:59:55 +00:00
emil b9c3b55680 DEVX-27: fix: add Docker socket diagnostics to start_docker
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Successful in 33s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 39s
2026-06-24 01:50:46 +00:00
devx-ci-bot d398c8e971 release: v0.9.5 [skip ci] 2026-06-24 01:37:15 +00:00
emil 39526d8e6a DEVX-26: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 46s
Post-merge / vikunja (push) Successful in 10s
Post-merge / badges (push) Successful in 41s
Post-merge / sync-wiki (push) Successful in 43s
2026-06-24 01:36:21 +00:00
emil 37730e2187 DEVX-25: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 41s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 52s
2026-06-24 01:24:00 +00:00
devx-ci-bot e2f66ca70a release: v0.9.4 [skip ci] 2026-06-24 01:11:08 +00:00
emil 0b88c211f1 DEVX-24: fix: use separate Docker socket for DinD in CI
Post-merge / detect-type (push) Successful in 14s
Post-merge / validate-commit-msg (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 43s
Post-merge / release (push) Successful in 51s
Post-merge / vikunja (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 44s
2026-06-24 01:08:10 +00:00
devx-ci-bot ea4ee0d303 release: v0.9.3 [skip ci] 2026-06-24 02:46:26 +02:00
emil 16fba17b03 DEVX-23: fix: use tempfile for dockerd log to fix CI permission error
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 40s
Post-merge / vikunja (push) Successful in 22s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / badges (push) Successful in 54s
2026-06-24 00:45:28 +00:00
18 changed files with 622 additions and 83 deletions
+2 -2
View File
@@ -120,8 +120,8 @@ jobs:
auto-merge:
# Auto-merge runs after all CI checks pass. It reads the task ID
# from .taskid file, validates the PR title, and squash-merges.
# No manual label or review needed — CI is the quality gate.
# from the branch name (falling back to .taskid file), validates
# the PR title, and squash-merges.
needs: [quality, detect-changes, pr-review]
if: github.event_name == 'pull_request'
runs-on: docker
+1 -1
View File
@@ -1 +1 @@
DEVX-22
DEVX-30
+35
View File
@@ -285,6 +285,37 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
| PR title | `DEVX-N: <vikunja task title>` | `DEVX-12: Add release automation` |
| Merge commit | `DEVX-N <conventional commit>` | `DEVX-12 feat: add release script` |
### Task ID Resolution
`auto_merge` resolves the task ID from the branch name first (e.g.
`DEVX-12-fix-foo``DEVX-12`), falling back to the `.taskid` file
for branches without a task ID prefix. If both exist and disagree,
a warning is printed and the branch task ID is preferred.
**When creating a new branch from an existing branch**, the `.taskid`
file may be stale (it contains the old branch's task ID). Either:
1. Update `.taskid` to match the new branch's task ID, or
2. Delete `.taskid` — the branch name is the primary source of truth
### Workflow `auto-merge` Job and `always()`
When `auto-merge` depends on a job that can be skipped (e.g.
`molecule-tests`), the `if:` condition MUST include `always() &&`
at the start. Without it, Gitea Actions skips `auto-merge` when any
dependency is skipped, even if the condition explicitly allows
`result == 'skipped'`.
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
needs.pr-review.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
## Config System
devx uses environment variables with `.env` file fallback for configuration.
@@ -295,6 +326,10 @@ devx uses environment variables with `.env` file fallback for configuration.
|----------|---------|-------------|
| `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL |
| `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL |
| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls |
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `REPO_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
+53
View File
@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [0.9.10] - 2026-06-24
### Bug Fixes
- Retrospective fixes for CI/CD friction
## [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
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.9.2"
__version__ = "0.9.10"
+34 -9
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Auto-merge PR when all CI checks pass.
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
(falling back to branch name extraction for backwards compatibility),
Runs as the final job in ci.yml. Reads the task ID from the branch name
(falling back to ``.taskid`` file for branches without a task ID prefix),
validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID.
@@ -63,20 +63,45 @@ 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.
If both sources exist and disagree, a warning is printed and the
branch task ID is preferred (it is the current source of truth).
"""
branch_task_id = extract_task_id(branch)
if branch_task_id:
# Check for stale .taskid file that disagrees with branch name
path = Path(TASKID_FILE)
if path.exists():
file_task_id = path.read_text(encoding="utf-8").strip()
if file_task_id and file_task_id != branch_task_id:
click.echo(
_(
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). "
"Using branch task ID. Update or delete .taskid to silence this warning.",
file_id=file_task_id,
branch_id=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:
click.echo(
_(
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
task_id=task_id,
branch=branch,
)
)
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
View File
@@ -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."))
+3 -2
View File
@@ -13,8 +13,9 @@ import re
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
# Organization defaults
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss")
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
# No default: prevents silent 404s when the wrong owner is used.
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
+11
View File
@@ -321,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)
+134 -14
View File
@@ -1,10 +1,14 @@
#!/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
Docker socket mounted, but molecule needs a local Docker daemon to create
nested containers. This module always starts ``dockerd`` in the background
and waits for it to become ready.
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::
@@ -13,8 +17,11 @@ Usage::
from __future__ import annotations
import glob
import os
import subprocess # nosec B404
import sys
import tempfile
import time
import click
@@ -22,33 +29,130 @@ import click
from devx.i18n import _
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:
"""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
["docker", "info"],
capture_output=True,
check=False,
env={**os.environ, "DOCKER_HOST": docker_host},
)
return result.returncode == 0
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Start dockerd in the background and wait for it to be ready.
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()}")
Always starts a local dockerd even if ``docker info`` succeeds,
because the host socket may be mounted but not suitable for
molecule's nested container creation.
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.
"""
click.echo(_("Starting Docker daemon..."))
log_file = open(DOCKERD_LOG, "w") # noqa: SIM115
# 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"],
[
"dockerd",
"--storage-driver",
"vfs",
"-H",
f"unix://{DOCKER_SOCK}",
],
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
@@ -60,7 +164,17 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
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
@@ -74,6 +188,12 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
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)
+13
View File
@@ -155,6 +155,19 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
if "/" in repo and owner is None:
parts = repo.split("/", 1)
owner, repo = parts[0], parts[1]
click.echo(
_(
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
owner=owner,
repo=repo,
)
)
if owner is None:
owner = REPO_OWNER
+35 -7
View File
@@ -419,6 +419,13 @@
"ru": "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": {
"bg": "Docker daemon failed to start",
"de": "Docker-Daemon konnte nicht gestartet werden",
@@ -552,6 +559,13 @@
"ru": "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}": {
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
@@ -797,6 +811,13 @@
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
"en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME",
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
},
"PASSED: {pair}": {
"bg": "PASSED: {pair}",
"de": "PASSED: {pair}",
@@ -930,13 +951,6 @@
"ru": "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...": {
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
@@ -979,6 +993,13 @@
"ru": "Task ID: {task_id}",
"zh": "Task ID: {task_id}"
},
"Task ID from .taskid file: {task_id} (not found in branch name '{branch}')": {
"bg": "Task ID от .taskid файл: {task_id} (не е намерен в името на клона '{branch}')",
"de": "Task ID aus .taskid-Datei: {task_id} (nicht im Branch-Namen '{branch}' gefunden)",
"en": "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')",
"ru": "Task ID из файла .taskid: {task_id} (не найден в имени ветки '{branch}')",
"zh": "来自 .taskid 文件的 Task ID: {task_id}(在分支名 '{branch}' 中未找到)"
},
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
@@ -1063,6 +1084,13 @@
"ru": "WARNING: --skip-tests passed — skipping test verification.",
"zh": "WARNING: --skip-tests passed — skipping test verification."
},
"WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.": {
"bg": "ВНИМАНИЕ: .taskid файл ({file_id}) не съвпада с името на клона ({branch_id}). Използва се task ID от клона. Актуализирайте или изтрийте .taskid за да премахнете това предупреждение.",
"de": "WARNUNG: .taskid-Datei ({file_id}) stimmt nicht mit Branch-Namen ({branch_id}) überein. Branch-Task-ID wird verwendet. Aktualisieren oder löschen Sie .taskid, um diese Warnung zu unterdrücken.",
"en": "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.",
"ru": "ВНИМАНИЕ: файл .taskid ({file_id}) не совпадает с именем ветки ({branch_id}). Используется Task ID из ветки. Обновите или удалите .taskid, чтобы скрыть это предупреждение.",
"zh": "警告:.taskid 文件 ({file_id}) 与分支名 ({branch_id}) 不一致。使用分支 Task ID。更新或删除 .taskid 以消除此警告。"
},
"Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.",
+18 -1
View File
@@ -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]
@@ -39,6 +46,16 @@ class TestReadTaskid:
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-42-test") == "DEVX-42"
def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
# Branch name takes priority, but stale .taskid should produce a warning
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
captured = capsys.readouterr()
assert "WARNING" in captured.out
assert "DEVX-60" in captured.out
assert "DEVX-19" in captured.out
# -- extract_task_id (legacy fallback) --
+1 -1
View File
@@ -37,7 +37,7 @@ class TestConfigConstants:
assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None:
assert REPO_OWNER == "oblachno-oss"
assert REPO_OWNER == ""
def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX"
+34
View File
@@ -163,3 +163,37 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once()
args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop"
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
# Verify GiteaClient was constructed with parsed owner and repo (positional)
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
assert call_args[0][3] == "infra" # repo is 4th positional arg
@patch.dict(
"os.environ",
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
clear=True,
)
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner
assert call_args[0][3] == "infra" # repo
+15
View File
@@ -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(
+11 -2
View File
@@ -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")
+208 -37
View File
@@ -1,89 +1,239 @@
"""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 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:
@patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
assert is_docker_ready() is True
mock_run.assert_called_once_with(["docker", "info"], capture_output=True, check=False)
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.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.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_starts_successfully(
self,
mock_file: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
def test_rootless_socket_available(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> 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]
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()
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.time.sleep")
@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(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> 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()
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.is_docker_ready")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_ready_on_first_check(
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_log_read_error(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ready.return_value = True
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
mock_sleep.assert_not_called()
"""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.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.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("builtins.open", new_callable=mock_open)
def test_custom_timeout(
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_local_daemon_ready_on_first_check(
self,
mock_file: MagicMock,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_ready: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
# 9 iterations not ready, 10th ready.
mock_ready.side_effect = [False] * 9 + [True]
assert start_docker_daemon(timeout=10) is True
assert mock_sleep.call_count == 9
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:
@@ -105,3 +255,24 @@ class TestMain:
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