Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ca622838 | ||
|
|
3f2d19d7ac | ||
|
|
c839d49fe3 | ||
|
|
93b5d2f926 | ||
|
|
131c04c9d0 | ||
|
|
8e9681cf7d | ||
|
|
6631525a1d | ||
|
|
6985030a3c | ||
|
|
4d073f3beb | ||
|
|
037d7b0d16 | ||
|
|
9cb706e387 | ||
|
|
5bd6158f2a | ||
|
|
05aa2ffe76 | ||
|
|
b9c3b55680 | ||
|
|
d398c8e971 | ||
|
|
39526d8e6a | ||
|
|
37730e2187 | ||
|
|
e2f66ca70a | ||
|
|
0b88c211f1 | ||
|
|
ea4ee0d303 | ||
|
|
16fba17b03 |
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "0.9.10"
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) --
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user