Compare commits

...
38 Commits
Author SHA1 Message Date
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 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 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 2026-06-24 01:59:55 +00:00
emil b9c3b55680 DEVX-27: fix: add Docker socket diagnostics to start_docker 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 2026-06-24 01:36:21 +00:00
emil 37730e2187 DEVX-25: fix: use host Docker socket with DOCKER_HOST fallback to local dockerd 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 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 2026-06-24 00:45:28 +00:00
devx-ci-bot 011cf3e093 release: v0.9.2 [skip ci] 2026-06-24 02:34:47 +02:00
emil daf99c5fed DEVX-22: fix: use vfs storage driver for Docker-in-Docker in CI 2026-06-24 00:33:48 +00:00
emil 7154e3ad7c DEVX-21: chore: trigger auto-merge after Vikunja title fix 2026-06-24 00:28:18 +00:00
devx-ci-bot 6053fb9fba release: v0.9.1 [skip ci] 2026-06-24 01:50:03 +02:00
emil e76741bfad DEVX-21: fix: always start dockerd in CI runner for molecule tests 2026-06-23 23:49:13 +00:00
devx-ci-bot f206a9cd8d release: v0.9.0 [skip ci] 2026-06-24 01:29:24 +02:00
emil b4b7428f9c DEVX-20: feat: extract Docker daemon start to tested Python module 2026-06-23 23:28:20 +00:00
devx-ci-bot 0d9e76a838 release: v0.8.5 [skip ci] 2026-06-24 01:02:21 +02:00
emil 034cbde2f7 DEVX-19: fix: retry pip install with --ignore-installed only on failure 2026-06-23 23:01:32 +00:00
devx-ci-bot e0abe6f176 release: v0.8.4 [skip ci] 2026-06-23 22:28:29 +00:00
emil 15f6837dc2 DEVX-18: fix: add --ignore-installed to pip in CI to bypass debian packages 2026-06-23 22:27:35 +00:00
devx-ci-bot b4dda91e24 release: v0.8.3 [skip ci] 2026-06-23 21:58:33 +00:00
emil 3e21e774f7 DEVX-17: fix: pass --break-system-packages to pip in CI environments 2026-06-23 21:57:37 +00:00
emil 7c11215e57 DEVX-16: fix: lower check_test_speed threshold to 4 seconds 2026-06-23 20:35:53 +00:00
devx-ci-bot 5384269c83 release: v0.8.2 [skip ci] 2026-06-23 21:44:03 +02:00
emil b3d0dd8ca7 DEVX-15: fix: encode spaces in pair commands to survive shell word-splitting 2026-06-23 19:42:56 +00:00
devx-ci-bot a7dcaee5c6 release: v0.8.1 [skip ci] 2026-06-23 20:45:02 +02:00
emil 02f8d3757b DEVX-14: fix: set fresh MOLECULE_HOME per pair to avoid stale config cache 2026-06-23 18:43:58 +00:00
devx-ci-bot 4311fb7648 release: v0.8.0 [skip ci] 2026-06-23 20:11:18 +02:00
emil 2ead959fcf DEVX-14: feat: fix molecule platforms to use sleep infinity, add --platforms-file 2026-06-23 18:10:19 +00:00
devx-ci-bot 9a60009d29 release: v0.7.0 [skip ci] 2026-06-23 18:26:54 +02:00
emil c20dfd185a DEVX-13: feat: add per-test timing quality gate to check_test_speed 2026-06-23 16:25:50 +00:00
devx-ci-bot 547fef4f27 release: v0.6.0 [skip ci] 2026-06-23 15:38:44 +02:00
emil 23183df7c7 DEVX-12: feat: add opentofu helpers, CLI entry points, shared utility, and CI improvements 2026-06-23 13:37:10 +00:00
41 changed files with 3611 additions and 301 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 10 python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Documentation coverage check - name: Documentation coverage check
env: env:
PYTHONPATH: src PYTHONPATH: src
+1 -1
View File
@@ -1 +1 @@
DEVX-10 DEVX-29
+13 -3
View File
@@ -55,16 +55,20 @@ src/devx/
├── translations.json # Translation strings (en, bg) ├── translations.json # Translation strings (en, bg)
├── ci/ # CI/CD automation modules (run by workflows) ├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog │ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry │ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation │ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── classify_changes.py # User-facing vs workflow-only change detection │ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── detect_release_commit.py # Detect release commits on master │ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation │ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review │ ├── pr_review.py # Automated PR review
│ ├── post_merge.py # Vikunja task updates after merge │ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki │ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges │ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures │ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
│ ├── check_translations.py # Translation completeness check │ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check │ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
@@ -73,7 +77,13 @@ src/devx/
│ ├── check_test_speed.py # Measure unit test execution time │ ├── check_test_speed.py # Measure unit test execution time
│ ├── configure_repo.py # Branch protection and label setup │ ├── configure_repo.py # Branch protection and label setup
│ └── generate_badges.py # Badge SVG generation │ └── generate_badges.py # Badge SVG generation
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects) └── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
``` ```
### Key Design Principles ### Key Design Principles
+105
View File
@@ -2,6 +2,111 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.9.8] - 2026-06-24
### Bug Fixes
- Use DOCKER_HOST env var in is_docker_ready + scan all rootless sockets
## [0.9.7] - 2026-06-24
### Bug Fixes
- Add rootless socket fallback and GITHUB_ENV export
## [0.9.6] - 2026-06-24
### Bug Fixes
- Add Docker socket diagnostics to start_docker
- Add Docker socket diagnostics to start_docker
## [0.9.5] - 2026-06-24
### Bug Fixes
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
- Use host Docker socket with DOCKER_HOST fallback to local dockerd
## [0.9.4] - 2026-06-24
### Bug Fixes
- Use separate Docker socket for DinD in CI
## [0.9.3] - 2026-06-24
### Bug Fixes
- Use tempfile for dockerd log to fix CI permission error
## [0.9.2] - 2026-06-24
### Bug Fixes
- Use vfs storage driver for Docker-in-Docker in CI
## [0.9.1] - 2026-06-23
### Bug Fixes
- Always start dockerd in CI runner for molecule tests
## [0.9.0] - 2026-06-23
### Features
- Extract Docker daemon start to tested Python module
## [0.8.5] - 2026-06-23
### Bug Fixes
- Retry pip install with --ignore-installed only on failure
## [0.8.4] - 2026-06-23
### Bug Fixes
- Add --ignore-installed to pip in CI to bypass debian packages
## [0.8.3] - 2026-06-23
### Bug Fixes
- Lower check_test_speed threshold to 4 seconds
- Pass --break-system-packages to pip in CI environments
## [0.8.2] - 2026-06-23
### Bug Fixes
- Encode spaces in pair commands to survive shell word-splitting
## [0.8.1] - 2026-06-23
### Bug Fixes
- Set fresh MOLECULE_HOME per pair to avoid stale config cache
## [0.8.0] - 2026-06-23
### Features
- Fix molecule platforms to use sleep infinity, add --platforms-file
## [0.7.0] - 2026-06-23
### Features
- Add per-test timing quality gate to check_test_speed
## [0.6.0] - 2026-06-23
### Features
- Add opentofu helpers, CLI entry points, shared utility, and CI improvements
## [0.5.0] - 2026-06-23 ## [0.5.0] - 2026-06-23
### Features ### Features
+19 -1
View File
@@ -24,10 +24,22 @@ Detect whether the latest git commit is a release commit (`release: vX.Y.Z [skip
Discover available Gitea Actions runners for dynamic job distribution. Discover available Gitea Actions runners for dynamic job distribution.
### `devx ci distribute-files`
Distribute files across parallel runners (round-robin). Used for splitting test suites or workloads across CI runners.
### `devx ci doc-coverage` ### `devx ci doc-coverage`
Check documentation coverage for CLI commands and major modules. Check documentation coverage for CLI commands and major modules.
### `devx ci integration-guard`
Run pytest with cross-runner failure detection and JUnit XML output. Monitors other runners for failures and aborts early if a critical failure is detected.
### `devx ci merge-junit`
Merge multiple JUnit XML reports from parallel runners into a single consolidated report.
### `devx ci notify-failure` ### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails. Create a Gitea issue when a CI workflow fails.
@@ -64,7 +76,13 @@ Validate commit messages for conventional commit format.
### `devx tools check-test-speed` ### `devx tools check-test-speed`
Run unit tests and enforce a maximum execution-time budget. Run unit tests and enforce execution-time budgets:
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
```bash
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
```
### `devx tools configure-repo` ### `devx tools configure-repo`
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# pre-commit hook: fail if unit tests take longer than 10 seconds. # pre-commit hook: fail if unit tests are too slow.
# Aligned with CI timeout (ci.yml uses --max-seconds 10). # Checks both total suite time (10s) and per-test time (0.5s).
# Aligned with CI (ci.yml uses same thresholds).
set -e set -e
export PYTHONPATH=src export PYTHONPATH=src
python3 -m devx.tools.check_test_speed --max-seconds 10 python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.5.0" __version__ = "0.9.8"
+18
View File
@@ -0,0 +1,18 @@
"""Shared utilities for CI modules."""
from __future__ import annotations
import subprocess # nosec B404
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
+1 -13
View File
@@ -138,6 +138,7 @@ from typing import Any
import click import click
from devx.ci._shared import get_latest_tag
from devx.i18n import _ from devx.i18n import _
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -494,19 +495,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
return output.split("\n") return output.split("\n")
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Backward-compatible API (used by release.py and CI workflows) # Backward-compatible API (used by release.py and CI workflows)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Distribute a list of files across N parallel runners (round-robin).
Generic file-based test distribution for CI matrix jobs. Discovers files
matching a glob pattern, sorts them for deterministic ordering, then
assigns them round-robin to *max_runners* groups. The assigned group for
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
Usage::
python3 -m devx.ci.distribute_files \\
--pattern "tests/integration/test_*.py" \\
--runner-index 1 \\
--max-runners 3 \\
--github-env --skip-if-excess
"""
from __future__ import annotations
import glob
import os
import click
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
def discover_files(pattern: str) -> list[str]:
"""Return sorted list of file paths matching *pattern*."""
return sorted(glob.glob(pattern))
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
"""Split *files* into *max_runners* balanced groups (round-robin)."""
groups: list[list[str]] = [[] for _ in range(max_runners)]
for i, f in enumerate(files):
groups[i % max_runners].append(f)
return groups
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of files assigned to *runner_index* (0-based)."""
groups = distribute(files, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--github-env",
is_flag=True,
default=False,
help="Write ASSIGNED_FILES and SKIP to $GITHUB_ENV.",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def main(pattern: str, runner_index: int | None, max_runners: int, github_env: bool, skip_if_excess: bool) -> None:
files = discover_files(pattern)
if runner_index is None:
groups = distribute(files, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("ASSIGNED_FILES", "")
_write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned)
if github_env:
_write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Run integration tests with cross-runner failure detection.
Wraps ``pytest`` with the same Gitea API polling mechanism used by
``molecule_ci_guard``. If any other integration-tests matrix runner
reports failure, the current pytest subprocess is killed and this runner
exits early with code 1.
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
to the pytest invocation).
Usage::
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- test_file1.py test_file2.py
# With pytest options
python3 -m devx.ci.integration_guard \\
--junit-output junit-results/runner-1.xml \\
-- -x -v --tb=short test_file1.py
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index).
GITEA_REPOSITORY Repository in "owner/repo" format.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess # nosec B404
import sys
import threading
import time
import click
from devx.i18n import _
from devx.molecule.molecule_ci_guard import (
poll_for_other_failures,
)
POLL_INTERVAL = 10
@click.command(context_settings={"ignore_unknown_options": True})
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
@click.option(
"--junit-output",
default=None,
help="Path for JUnit XML output (passed to pytest as --junitxml).",
)
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
"""Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
stop_event = threading.Event()
failed_event = threading.Event()
if gitea_url and token and run_id:
poller = threading.Thread(
target=poll_for_other_failures,
args=(
gitea_url,
owner,
repo,
token,
run_id,
job_name,
current_index,
stop_event,
failed_event,
),
daemon=True,
)
poller.start()
cmd = [sys.executable, "-m", "pytest"]
if junit_output:
cmd.extend(["--junitxml", junit_output])
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
process = subprocess.Popen( # nosec B603
cmd,
preexec_fn=os.setsid,
)
try:
while process.poll() is None:
if failed_event.is_set():
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
click.echo(_("Integration tests cancelled — another runner failed."))
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
sys.exit(1)
finally:
stop_event.set()
rc = process.returncode
if rc != 0:
click.echo(_("Integration tests failed with exit code {code}", code=rc))
else:
click.echo(_("Integration tests passed."))
sys.exit(rc)
if __name__ == "__main__": # pragma: no cover
cli()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Merge multiple JUnit XML reports into a single report.
Used by CI workflows to consolidate JUnit XML files produced by
parallel matrix runners into a single merged report for archival
and dashboard consumption.
Usage::
python3 -m devx.ci.merge_junit \\
--pattern "junit-results/runner-*.xml" \\
--output junit-merged.xml
Exit code is non-zero if any merged test suite reports failures,
making this suitable as a CI gating step after matrix jobs.
"""
from __future__ import annotations
import glob
import sys
import xml.etree.ElementTree as ET # nosec B405
import click
from devx.i18n import _
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
Returns ``(merged_element, total_tests, total_failures)``.
If no files match, returns an empty ``<testsuites>`` with zero counts.
"""
files = sorted(glob.glob(pattern))
merged = ET.Element("testsuites")
total_tests = 0
total_failures = 0
for f in files:
tree = ET.parse(f) # nosec B314
suite = tree.getroot()
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
if suite.tag == "testsuites":
for child in suite:
merged.append(child)
total_tests += int(child.get("tests", 0))
total_failures += int(child.get("failures", 0))
else:
merged.append(suite)
total_tests += int(suite.get("tests", 0))
total_failures += int(suite.get("failures", 0))
merged.set("tests", str(total_tests))
merged.set("failures", str(total_failures))
return merged, total_tests, total_failures
@click.command()
@click.option(
"--pattern",
default="junit-results/runner-*.xml",
show_default=True,
help="Glob pattern for input JUnit XML files.",
)
@click.option(
"--output",
default="junit-merged.xml",
show_default=True,
help="Output path for the merged JUnit XML file.",
)
def main(pattern: str, output: str) -> None:
merged, total_tests, total_failures = merge_files(pattern)
if total_tests == 0:
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
return
ET.indent(merged)
tree = ET.ElementTree(merged)
tree.write(output, encoding="UTF-8", xml_declaration=True)
click.echo(
_(
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
count=len(glob.glob(pattern)),
tests=total_tests,
failures=total_failures,
output=output,
)
)
if total_failures > 0:
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+61 -2
View File
@@ -10,13 +10,20 @@ Usage:
--repo <owner/repo> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
--commit <commit_sha> --commit <commit_sha> \
--auto-login
With ``--auto-login``, the script configures the tea CLI login profile
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
import shutil
import subprocess # nosec B404
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -30,6 +37,49 @@ load_dotenv()
logger = logging.getLogger("devx") logger = logging.getLogger("devx")
def _configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or REPO_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("notify_failure: tea not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
return
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int: def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index. """Create issue via tea CLI. Returns issue index.
@@ -62,11 +112,20 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
@click.option("--run-id", required=True, help="CI run ID.") @click.option("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.") @click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.") @click.option("--commit", required=True, help="Commit SHA.")
def main(repo: str, run_id: str, workflow: str, commit: str) -> None: @click.option(
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
)
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("REPO_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if auto_login:
_configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})" title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = ( body = (
f"The **{workflow}** workflow failed.\n\n" f"The **{workflow}** workflow failed.\n\n"
+24 -14
View File
@@ -164,7 +164,14 @@ def _default_gitea_registry_url() -> str:
"or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI " "or a URL derived from GITEA_API_URL. When set, publishes to Gitea PyPI "
"instead of standard PyPI (unless PYPI_TOKEN is also set).", "instead of standard PyPI (unless PYPI_TOKEN is also set).",
) )
def main(tag: str, repo: str, registry_url: str | None) -> None: @click.option(
"--skip-build",
is_flag=True,
default=False,
help="Skip package build and PyPI publish (for non-Python repos that only "
"need a Gitea release with git-cliff notes).",
)
def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> None:
gitea_token = os.environ.get("REPO_TOKEN", "") gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token: if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
@@ -177,21 +184,24 @@ def main(tag: str, repo: str, registry_url: str | None) -> None:
if not registry_url: if not registry_url:
registry_url = _default_gitea_registry_url() registry_url = _default_gitea_registry_url()
build_package() if not skip_build:
build_package()
if pypi_token: if pypi_token:
# Standard PyPI flow takes precedence when PYPI_TOKEN is set # Standard PyPI flow takes precedence when PYPI_TOKEN is set
publish_to_pypi(pypi_token) publish_to_pypi(pypi_token)
elif registry_url: elif registry_url:
# Gitea PyPI registry flow # Gitea PyPI registry flow
publish_to_gitea_registry(registry_url, gitea_token) publish_to_gitea_registry(registry_url, gitea_token)
else: else:
click.echo( click.echo(
_( _(
"PYPI_TOKEN not set and no registry URL configured — " "PYPI_TOKEN not set and no registry URL configured — "
"skipping PyPI publish. No worries, we'll just create the Gitea release." "skipping PyPI publish. No worries, we'll just create the Gitea release."
)
) )
) else:
click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo) tea = TeaCLI(repo=repo)
release_body = generate_release_notes(tag) release_body = generate_release_notes(tag)
+29 -6
View File
@@ -17,9 +17,11 @@ Usage::
from __future__ import annotations from __future__ import annotations
import contextlib
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -160,13 +162,34 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
default=False, default=False,
help="Skip updating README with cache-busting URLs (for local testing).", help="Skip updating README with cache-busting URLs (for local testing).",
) )
def main(output_dir: str, branch: str, no_readme_update: bool) -> None: @click.option(
"--retries",
default=1,
type=int,
help="Number of attempts on git push failures (default: 1, no retry). "
"Between attempts, fetches latest master and waits 10s.",
)
def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) -> None:
"""Generate badges and push them to the badges branch.""" """Generate badges and push them to the badges branch."""
fetch_latest_master(branch) last_error: Exception | None = None
generate_badges(output_dir) for attempt in range(1, retries + 1):
badges_sha = push_to_badges_branch(output_dir) try:
if not no_readme_update: fetch_latest_master(branch)
update_readme_with_badge_sha(badges_sha) generate_badges(output_dir)
badges_sha = push_to_badges_branch(output_dir)
if not no_readme_update:
update_readme_with_badge_sha(badges_sha)
return
except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc
if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch)
else:
click.echo(f"Badge push failed after {retries} attempts: {exc}")
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+1 -8
View File
@@ -43,6 +43,7 @@ import sys
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=. from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _ from devx.i18n import _
@@ -72,14 +73,6 @@ def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subpro
return result return result
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
if result.returncode != 0:
return ""
return result.stdout.strip()
def tag_exists(tag: str) -> bool: def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists.""" """Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False) result = run_cmd(["git", "tag", "-l", tag], check=False)
+21
View File
@@ -151,6 +151,27 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
_run_module("devx.ci.validate_commit_msg", list(args)) _run_module("devx.ci.validate_commit_msg", list(args))
@ci.command("distribute-files")
@click.argument("args", nargs=-1)
def ci_distribute_files(args: tuple[str, ...]) -> None:
"""Distribute files across parallel runners (round-robin)."""
_run_module("devx.ci.distribute_files", list(args))
@ci.command("merge-junit")
@click.argument("args", nargs=-1)
def ci_merge_junit(args: tuple[str, ...]) -> None:
"""Merge multiple JUnit XML reports into a single report."""
_run_module("devx.ci.merge_junit", list(args))
@ci.command("integration-guard")
@click.argument("args", nargs=-1)
def ci_integration_guard(args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection and JUnit output."""
_run_module("devx.ci.integration_guard", list(args))
@cli.group() @cli.group()
def tools() -> None: def tools() -> None:
"""Development tool commands.""" """Development tool commands."""
+150 -6
View File
@@ -25,10 +25,11 @@ from pathlib import Path
import click import click
from devx.i18n import _ from devx.i18n import _
from devx.molecule.platforms import PLATFORMS from devx.molecule.platforms import PLATFORMS, load_platforms
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule") MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
DEFAULT_ROLES_ROOT = Path("ansible/roles")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -40,7 +41,8 @@ class TestPair:
def encode(self) -> str: def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption.""" """Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}" cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@staticmethod @staticmethod
def decode(encoded: str) -> TestPair: def decode(encoded: str) -> TestPair:
@@ -48,7 +50,31 @@ class TestPair:
parts = encoded.split("|") parts = encoded.split("|")
return TestPair( return TestPair(
scenario=parts[0], scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]}, platform={"name": parts[1], "image": parts[2], "command": parts[3].replace("__SPACE__", " ")},
)
@dataclass(frozen=True)
class MultiRoleTestPair:
"""A (role, scenario, platform) combination for multi-role projects."""
role: str
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string: ``role|scenario|platform_name|image|command``."""
cmd = self.platform["command"].replace(" ", "__SPACE__")
return f"{self.role}|{self.scenario}|{self.platform['name']}|{self.platform['image']}|{cmd}"
@staticmethod
def decode(encoded: str) -> MultiRoleTestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return MultiRoleTestPair(
role=parts[0],
scenario=parts[1],
platform={"name": parts[2], "image": parts[3], "command": parts[4].replace("__SPACE__", " ")},
) )
@@ -62,6 +88,33 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
return sorted(scenarios) return sorted(scenarios)
def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]:
"""Discover (role, scenario) pairs across all roles under *roles_root*.
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
``common`` and directories starting with ``_``. Returns a sorted list of
``(role_name, scenario_name)`` tuples.
"""
if roles_root is None:
roles_root = DEFAULT_ROLES_ROOT
if not roles_root.is_dir():
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
pairs: list[tuple[str, str]] = []
for role_dir in sorted(roles_root.iterdir()):
if not role_dir.is_dir():
continue
mol_dir = role_dir / "molecule"
if not mol_dir.is_dir():
continue
for scenario_dir in mol_dir.iterdir():
if not scenario_dir.is_dir():
continue
if scenario_dir.name.startswith("_") or scenario_dir.name == "common":
continue
pairs.append((role_dir.name, scenario_dir.name))
return pairs
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]: def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms.""" """Build the full cross-product of scenarios and platforms."""
if platforms is None: if platforms is None:
@@ -69,6 +122,36 @@ def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = N
return [TestPair(s, p) for s in scenarios for p in platforms] return [TestPair(s, p) for s in scenarios for p in platforms]
def build_multi_role_pairs(
role_scenarios: list[tuple[str, str]],
platforms: list[dict[str, str]] | None = None,
) -> list[MultiRoleTestPair]:
"""Build the full cross-product of (role, scenario) pairs and platforms."""
if platforms is None:
platforms = PLATFORMS
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def multi_role_pairs_for_runner(
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
) -> list[MultiRoleTestPair]:
"""Return the subset of multi-role pairs assigned to *runner_index* (0-based)."""
groups = distribute_multi_role(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]: def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin).""" """Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)] groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
@@ -142,6 +225,26 @@ def _write_github_env(key: str, value: str) -> None:
default=False, default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.", help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
) )
@click.option(
"--molecule-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Custom molecule directory (single-role mode). Default: ansible/roles/gitea-runner/molecule.",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Roles directory for multi-role discovery (scans */molecule/*/). "
"Use this for projects with multiple Ansible roles. Default: disabled (single-role mode).",
)
@click.option(
"--platforms-file",
type=click.Path(exists=True, file_okay=True, path_type=Path),
default=None,
help="JSON file with custom platform list (each entry: name, image, command). "
"Overrides the default platform matrix. Useful for projects with custom test images.",
)
def cli( def cli(
runner_index: int | None, runner_index: int | None,
max_runners: int, max_runners: int,
@@ -149,17 +252,58 @@ def cli(
list_platforms: bool, list_platforms: bool,
github_env: bool, github_env: bool,
skip_if_excess: bool, skip_if_excess: bool,
molecule_root: Path | None,
roles_root: Path | None,
platforms_file: Path | None,
) -> None: ) -> None:
scenarios = discover_scenarios() platforms = load_platforms(platforms_file)
# Multi-role mode: discover (role, scenario) pairs across all roles
if roles_root is not None:
role_scenarios = discover_multi_role_scenarios(roles_root)
if list_all:
for role, scenario in role_scenarios:
click.echo(f"{role}|{scenario}")
return
if list_platforms:
for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs_mr = build_multi_role_pairs(role_scenarios, platforms)
if runner_index is None:
groups = distribute_multi_role(pairs_mr, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("TEST_PAIRS", "")
_write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
zero_based = runner_index - 1
assigned = multi_role_pairs_for_runner(pairs_mr, zero_based, max_runners)
encoded = " ".join(p.encode() for p in assigned)
if github_env:
_write_github_env("TEST_PAIRS", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned pairs: {encoded}")
return
click.echo(encoded)
return
# Single-role mode (default or --molecule-root)
scenarios = discover_scenarios(molecule_root)
if list_all: if list_all:
for s in scenarios: for s in scenarios:
click.echo(s) click.echo(s)
return return
if list_platforms: if list_platforms:
for p in PLATFORMS: for p in platforms:
click.echo(f"{p['name']}|{p['image']}|{p['command']}") click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return return
pairs = build_pairs(scenarios) pairs = build_pairs(scenarios, platforms)
if runner_index is None: if runner_index is None:
groups = distribute(pairs, max_runners) groups = distribute(pairs, max_runners)
for i, group in enumerate(groups): for i, group in enumerate(groups):
+134 -13
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures. """Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``. Each pair is encoded as one of:
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command``
Pairs are executed one at a time (molecule scenarios share temp directories and Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe). Docker networks, so parallel execution within a single runner is unsafe).
@@ -9,8 +13,17 @@ A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner reports failure, the current molecule subprocess is killed and this runner
exits early with code 1. exits early with code 1.
Usage: JUnit XML is generated when ``--junit-output`` is provided, recording each
python3 -m devx.molecule.molecule_ci_guard <pair1> <pair2> ... pair as a testcase with pass/fail status and elapsed time.
Usage::
# Single-role (grm-style)
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
# With JUnit output
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
@@ -30,6 +43,7 @@ import subprocess # nosec B404
import sys import sys
import threading import threading
import time import time
import xml.etree.ElementTree as ET # nosec B405
from pathlib import Path from pathlib import Path
import click import click
@@ -95,9 +109,25 @@ def build_molecule_cmd(scenario: str) -> list[str]:
return cmd return cmd
def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
Supports both 4-part (single-role) and 5-part (multi-role) formats.
For 4-part pairs, role is empty (caller uses default role dir).
Spaces in the command field are encoded as ``__SPACE__`` to survive
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
"""
parts = pair.split("|")
if len(parts) == 4:
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
if len(parts) == 5:
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]: def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair.""" """Build environment for a single molecule pair."""
scenario, platform_name, platform_image, platform_command = pair.split("|") _role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
env = base_env.copy() env = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image env["MOLECULE_PLATFORM_IMAGE"] = platform_image
@@ -106,12 +136,75 @@ def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
elif "MOLECULE_PLATFORM_COMMAND" in env: elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"] del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true" env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
# from previous CI runs (causes "Instances missing" errors).
if "MOLECULE_HOME" not in env:
import tempfile
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
return env return env
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
"""Resolve the working directory for a molecule pair.
For multi-role pairs (role non-empty), uses ``roles_root/role``.
For single-role pairs, uses ``repo_root/ansible/roles/gitea-runner``.
"""
if role:
if roles_root is None:
roles_root = repo_root / "ansible" / "roles"
return roles_root / role
return repo_root / "ansible" / "roles" / "gitea-runner"
def write_junit_report(
output_path: str,
testcases: list[dict],
runner_index: int,
) -> None:
"""Write a JUnit XML report from collected test case results.
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
"""
suite = ET.Element(
"testsuite",
name=f"molecule-runner-{runner_index}",
tests=str(len(testcases)),
failures=str(sum(1 for tc in testcases if not tc["passed"])),
)
for tc in testcases:
classname = tc["role"] if tc["role"] else "molecule"
elem = ET.SubElement(
suite,
"testcase",
classname=classname,
name=tc["scenario"],
time=f"{tc['time']:.1f}",
)
if not tc["passed"]:
fail = ET.SubElement(elem, "failure")
fail.text = tc.get("error") or "molecule test failed"
tree = ET.ElementTree(suite)
ET.indent(tree)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
@click.command() @click.command()
@click.argument("pairs", nargs=-1, required=True) @click.argument("pairs", nargs=-1, required=True)
def cli(pairs: tuple[str, ...]) -> None: @click.option(
"--junit-output",
default=None,
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
)
@click.option(
"--roles-root",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=None,
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
)
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("REPO_TOKEN", "")
@@ -119,7 +212,7 @@ def cli(pairs: tuple[str, ...]) -> None:
job_name = os.environ.get("JOB_NAME", "molecule-tests") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx") repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
owner, sep, repo = repository.partition("/") owner, _sep, repo = repository.partition("/")
if not owner or not repo: if not owner or not repo:
owner, repo = "oblachno-oss", "devx" owner, repo = "oblachno-oss", "devx"
@@ -127,7 +220,6 @@ def cli(pairs: tuple[str, ...]) -> None:
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent.parent repo_root = Path(__file__).resolve().parent.parent.parent.parent
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
base_env = os.environ.copy() base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock") base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
@@ -154,24 +246,24 @@ def cli(pairs: tuple[str, ...]) -> None:
) )
poller.start() poller.start()
testcases: list[dict] = []
try: try:
for pair in pairs: for pair in pairs:
if failed_event.is_set(): if failed_event.is_set():
sys.exit(1) sys.exit(1)
parts = pair.split("|") role, scenario, platform_name, _img, _cmd = parse_pair(pair)
if len(parts) < 2:
raise click.ClickException(f"Invalid pair format: {pair!r} (expected at least 2 pipe-delimited parts)")
scenario = parts[0]
platform_name = parts[1]
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)) click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario) cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env) env = build_env_for_pair(pair, base_env)
cwd = resolve_role_dir(role, roles_root, repo_root)
start = time.time()
process = subprocess.Popen( # nosec B603 process = subprocess.Popen( # nosec B603
cmd, cmd,
cwd=str(role_dir), cwd=str(cwd),
env=env, env=env,
preexec_fn=os.setsid, preexec_fn=os.setsid,
) )
@@ -187,6 +279,18 @@ def cli(pairs: tuple[str, ...]) -> None:
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL) os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait() process.wait()
elapsed = time.time() - start
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": False,
"error": "Cancelled — another runner failed",
}
)
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(1) sys.exit(1)
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -196,13 +300,30 @@ def cli(pairs: tuple[str, ...]) -> None:
sys.exit(1) sys.exit(1)
rc = process.returncode rc = process.returncode
elapsed = time.time() - start
passed = rc == 0
testcases.append(
{
"role": role,
"scenario": scenario,
"time": elapsed,
"passed": passed,
"error": f"Exit code: {rc}" if not passed else None,
}
)
if rc != 0: if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
sys.exit(rc) sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair)) click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed.")) click.echo(_("All molecule tests passed."))
if junit_output:
write_junit_report(junit_output, testcases, current_index)
finally: finally:
stop_event.set() stop_event.set()
+33 -7
View File
@@ -10,13 +10,39 @@ dev tools and CI scripts.
from __future__ import annotations from __future__ import annotations
#: Supported OS platform matrix. import json
from pathlib import Path
#: Default supported OS platform matrix.
#: Each entry maps a short name to (image, command). #: Each entry maps a short name to (image, command).
#: The command must be systemd since rootless Docker requires #: Uses the project's pre-built molecule-test-base image with
#: loginctl/systemctl --user. #: ``sleep infinity`` (NOT systemd) to avoid cgroup v2 failures.
PLATFORMS: list[dict[str, str]] = [ PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"}, {
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"}, "name": "ubuntu-2604",
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"}, "image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"}, "command": "sleep infinity",
},
] ]
def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, str]]:
"""Load platforms from a JSON file, falling back to PLATFORMS.
Args:
platforms_file: Path to a JSON file with a list of platform dicts.
Each dict must have ``name``, ``image``, and ``command`` keys.
Returns:
List of platform dictionaries.
"""
if platforms_file is None:
return PLATFORMS
path = Path(platforms_file)
if not path.is_file():
return PLATFORMS
with path.open() as f:
data = json.load(f)
if not isinstance(data, list) or not data:
return PLATFORMS
return data
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Ensure Docker is available for molecule tests in CI.
CI runners (e.g. ``gitea/runner-images:ubuntu-latest``) may have the host's
Docker socket mounted. This module verifies Docker is accessible and
sets ``DOCKER_HOST`` explicitly so molecule's Python docker library
connects to the same socket as the Docker CLI.
If the host socket is not available, it tries the rootless socket, then
starts a local ``dockerd`` with the vfs storage driver (requires
privileged container).
Usage::
python3 -m devx.molecule.start_docker [--timeout 30]
"""
from __future__ import annotations
import glob
import os
import subprocess # nosec B404
import sys
import tempfile
import time
import click
from devx.i18n import _
DEFAULT_TIMEOUT = 30
DOCKER_SOCK = "/var/run/docker.sock"
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
def is_docker_ready() -> bool:
"""Check if Docker daemon is responding on the configured socket."""
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
env={**os.environ, "DOCKER_HOST": docker_host},
)
return result.returncode == 0
def _diagnose_socket() -> None:
"""Print diagnostic info about the Docker socket."""
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
click.echo(f"Socket path: {DOCKER_SOCK}")
click.echo(f"Socket exists: {os.path.exists(DOCKER_SOCK)}")
if os.path.exists(DOCKER_SOCK):
stat = os.stat(DOCKER_SOCK)
click.echo(f"Socket mode: {oct(stat.st_mode)}")
click.echo(f"Socket uid: {stat.st_uid}, gid: {stat.st_gid}")
# Check if it's a mount point
result = subprocess.run( # nosec B603 B607
["mount"],
capture_output=True,
check=False,
text=True,
)
docker_mounts = [line for line in result.stdout.splitlines() if "docker" in line.lower()]
if docker_mounts:
click.echo("Docker-related mounts:")
for line in docker_mounts:
click.echo(f" {line}")
else:
click.echo("No Docker-related mounts found")
# Check docker context
result = subprocess.run( # nosec B603 B607
["docker", "context", "ls"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"Docker contexts:\n{result.stdout}")
# Try docker info without DOCKER_HOST
result = subprocess.run( # nosec B603 B607
["docker", "info"],
capture_output=True,
check=False,
text=True,
)
click.echo(f"docker info (no DOCKER_HOST): rc={result.returncode}")
if result.returncode != 0:
click.echo(f" stderr: {result.stderr[:500]}")
else:
# Print server version and storage driver
for line in result.stdout.splitlines():
if "Server Version" in line or "Storage Driver" in line or "Docker Root Dir" in line:
click.echo(f" {line.strip()}")
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
"""Ensure Docker is ready for molecule tests.
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
returns immediately. If not, tries the rootless socket. If neither
works, starts a local ``dockerd`` with vfs storage driver (requires
privileged container).
Returns ``True`` if Docker is ready, ``False`` if it failed to
start within the timeout.
"""
# Point Docker CLI and Python library to the socket explicitly
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Diagnose socket state
click.echo("--- Docker socket diagnostics ---")
_diagnose_socket()
click.echo("--- End diagnostics ---")
# Check if host Docker is already available
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
# Try rootless socket (e.g. /run/user/994/docker.sock)
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
# Scan for any rootless sockets at other UIDs
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
if sock == ROOTLESS_SOCK:
continue
click.echo(f"Trying alternative rootless socket: {sock}")
os.environ["DOCKER_HOST"] = f"unix://{sock}"
if is_docker_ready():
click.echo(_("Docker daemon already running"))
return True
click.echo(_("Host Docker not available, starting local dockerd..."))
# Reset DOCKER_HOST to host socket for local dockerd
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
# Start local dockerd (requires privileged container)
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
mode="w", suffix="dockerd.log", delete=False
)
click.echo(f"dockerd log: {log_file.name}")
subprocess.Popen( # nosec B603 B607
[
"dockerd",
"--storage-driver",
"vfs",
"-H",
f"unix://{DOCKER_SOCK}",
],
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
for _i in range(timeout):
if is_docker_ready():
click.echo(_("Docker daemon started"))
return True
time.sleep(1)
# Print dockerd log on failure
click.echo(_("Docker daemon failed to start"))
click.echo("--- dockerd log ---")
try:
with open(log_file.name) as f:
log_content = f.read()
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
except OSError as e:
click.echo(f"Could not read log: {e}")
click.echo("--- End dockerd log ---")
return False
@click.command()
@click.option(
"--timeout",
default=DEFAULT_TIMEOUT,
type=int,
help="Seconds to wait for Docker daemon to start (default: 30).",
)
def main(timeout: int) -> None:
"""Start Docker daemon for CI molecule tests."""
if start_docker_daemon(timeout):
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
github_env = os.environ.get("GITHUB_ENV")
if github_env and os.environ.get("DOCKER_HOST"):
with open(github_env, "a") as f:
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
sys.exit(0)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""OpenTofu output helpers for CI/CD deployment scripts.
Provides reusable functions for extracting values from ``tofu output``
in a structured way. This eliminates duplicated ``subprocess.run``
boilerplate across deployment and smoke-test scripts.
Typical usage::
from devx.opentofu import get_tofu_output, get_tofu_vm_ip
vms = get_tofu_output("customer_vms", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="tofu/environments/staging",
env={"HCLOUD_TOKEN": token})
"""
from __future__ import annotations
import json
import subprocess # nosec B404
from pathlib import Path
from typing import Any
def get_tofu_output(
output_name: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> Any:
"""Run ``tofu output -json <output_name>`` and return parsed JSON.
Args:
output_name: The OpenTofu output name to query (e.g. ``customer_vms``).
cwd: Directory to run the command in (the tofu env directory).
env: Environment variables for the subprocess (e.g. ``{"HCLOUD_TOKEN": ...}``).
If ``None``, inherits the current environment.
Returns:
Parsed JSON value from the tofu output.
Raises:
RuntimeError: If ``tofu output`` exits with a non-zero code.
json.JSONDecodeError: If stdout is not valid JSON.
"""
result = subprocess.run( # nosec B603, B607
["tofu", "output", "-json", output_name],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
check=False,
env=env,
)
if result.returncode != 0:
raise RuntimeError(f"tofu output failed: {result.stderr}")
return json.loads(result.stdout)
def get_tofu_vm_ip(
output_name: str,
vm_key: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
ip_field: str = "ipv4",
) -> str:
"""Extract a VM IPv4 address from a tofu output map.
The output is expected to be a JSON object mapping VM names to objects
containing an IP field (default ``ipv4``)::
{"staging": {"ipv4": "1.2.3.4", ...}, ...}
Args:
output_name: The tofu output name (e.g. ``customer_vms``).
vm_key: The key inside the output map (e.g. ``"staging"``).
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
ip_field: The field name for the IP address (default ``ipv4``).
Returns:
The IP address string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(ip_field, ""))
def get_tofu_vm_field(
output_name: str,
vm_key: str,
field: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Extract an arbitrary field from a VM entry in tofu output.
Like :func:`get_tofu_vm_ip` but for any field (e.g. ``volume_linux_device``).
Args:
output_name: The tofu output name.
vm_key: The key inside the output map.
field: The field name to extract.
cwd: Directory to run the command in.
env: Environment variables for the subprocess.
Returns:
The field value as a string, or empty string if not found.
"""
data = get_tofu_output(output_name, cwd=cwd, env=env)
if not isinstance(data, dict):
return ""
return str(data.get(vm_key, {}).get(field, ""))
+95 -11
View File
@@ -1,12 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run unit tests and enforce a maximum execution-time budget. """Run unit tests and enforce execution-time budgets.
Checks two quality gates:
1. **Total suite time** must not exceed ``--max-seconds``.
2. **Per-test time** — no individual test may exceed ``--max-single-seconds``.
Usage: Usage:
python3 -m devx.tools.check_test_speed [--max-seconds N] python3 -m devx.tools.check_test_speed [--max-seconds N] [--max-single-seconds S]
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
that pytest emits per-test timing lines alongside the summary. Both the
total wall-clock time and individual test durations are parsed and validated.
""" """
from __future__ import annotations from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
@@ -14,18 +23,32 @@ import click
from devx.i18n import _ from devx.i18n import _
DEFAULT_MAX_SECONDS = 2.0 DEFAULT_MAX_SECONDS = 10.0
DEFAULT_MAX_SINGLE_SECONDS = 0.5
TEST_COMMAND = ["make", "test-unit"] TEST_COMMAND = ["make", "test-unit"]
# Matches pytest summary line: "234 passed in 0.70s"
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s") _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
# Matches per-test duration lines from --durations=0:
# 0.51s call tests/test_foo.py::test_bar
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
def run_tests() -> tuple[str, str]: def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr).""" """Execute the unit-test suite and return (stdout, stderr).
Sets ``PYTEST_ADDOPTS=--durations=0`` so pytest emits per-test timings.
"""
env = os.environ.copy()
existing = env.get("PYTEST_ADDOPTS", "")
env["PYTEST_ADDOPTS"] = f"--durations=0 {existing}".strip()
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
TEST_COMMAND, TEST_COMMAND,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=env,
) )
return result.stdout, result.stderr return result.stdout, result.stderr
@@ -43,8 +66,23 @@ def parse_duration(output: str) -> float:
raise click.ClickException(_("Could not parse test execution time from output.")) raise click.ClickException(_("Could not parse test execution time from output."))
def parse_per_test_durations(output: str) -> list[tuple[str, float]]:
"""Extract per-test timings from ``--durations=0`` output.
Returns a list of ``(test_name, seconds)`` tuples sorted by duration
(slowest first).
"""
durations: list[tuple[str, float]] = []
for line in output.splitlines():
match = _DURATION_LINE_RE.match(line.strip())
if match:
durations.append((match.group(2).strip(), float(match.group(1))))
durations.sort(key=lambda x: x[1], reverse=True)
return durations
def check_speed(duration: float, max_seconds: float) -> None: def check_speed(duration: float, max_seconds: float) -> None:
"""Validate duration is within budget; raise on violation.""" """Validate total duration is within budget; raise on violation."""
if duration > max_seconds: if duration > max_seconds:
raise click.ClickException( raise click.ClickException(
_( _(
@@ -57,19 +95,58 @@ def check_speed(duration: float, max_seconds: float) -> None:
) )
def main(max_seconds: float) -> None: def check_per_test_speed(
"""Run tests, parse timing, and enforce the budget.""" durations: list[tuple[str, float]],
max_single_seconds: float,
) -> list[str]:
"""Return a list of violation messages for tests exceeding the per-test limit.
An empty list means all tests are within budget.
"""
violations: list[str] = []
for name, elapsed in durations:
if elapsed > max_single_seconds:
violations.append(
_(
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). "
"Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
name=name,
elapsed=elapsed,
limit=max_single_seconds,
)
)
return violations
def main(max_seconds: float, max_single_seconds: float) -> None:
"""Run tests, parse timings, and enforce both budgets."""
stdout, stderr = run_tests() stdout, stderr = run_tests()
combined = stdout + "\n" + stderr combined = stdout + "\n" + stderr
click.echo(combined, err=False) click.echo(combined, err=False)
duration = parse_duration(combined) duration = parse_duration(combined)
check_speed(duration, max_seconds) check_speed(duration, max_seconds)
if max_single_seconds > 0:
per_test = parse_per_test_durations(combined)
violations = check_per_test_speed(per_test, max_single_seconds)
if violations:
msg = _(
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
count=len(violations),
limit=max_single_seconds,
)
click.echo(f"\n{msg}", err=True)
for v in violations:
click.echo(f" - {v}", err=True)
raise click.ClickException(msg)
click.echo( click.echo(
_( _(
"Unit tests passed in {duration:.2f}s (under {max}s limit).", "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
duration=duration, duration=duration,
max=max_seconds, max=max_seconds,
single=max_single_seconds,
) )
) )
@@ -80,10 +157,17 @@ def main(max_seconds: float) -> None:
type=float, type=float,
default=DEFAULT_MAX_SECONDS, default=DEFAULT_MAX_SECONDS,
show_default=True, show_default=True,
help="Maximum allowed execution time in seconds.", help="Maximum allowed total execution time in seconds.",
) )
def cli(max_seconds: float) -> None: @click.option(
main(max_seconds) "--max-single-seconds",
type=float,
default=DEFAULT_MAX_SINGLE_SECONDS,
show_default=True,
help="Maximum allowed per-test time in seconds (0 to disable).",
)
def cli(max_seconds: float, max_single_seconds: float) -> None:
main(max_seconds, max_single_seconds)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+18 -3
View File
@@ -26,9 +26,24 @@ def _run(cmd: list[str]) -> None:
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None: def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
"""Install the project with the specified extras in editable mode.""" """Install the project with the specified extras in editable mode.
In CI (system Python with PIP_BREAK_SYSTEM_PACKAGES=1), a first attempt
uses --break-system-packages. If that fails (e.g. debian-installed
packages without RECORD files), retry with --ignore-installed to skip
uninstalling system packages entirely.
"""
pip = str(Path(bin_dir) / "pip") pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", f".[{extras}]"]) cmd = [pip, "install", "-e", f".[{extras}]"]
if os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
cmd.append("--break-system-packages")
result = subprocess.run(cmd, check=False) # nosec B603
if result.returncode != 0 and os.environ.get("PIP_BREAK_SYSTEM_PACKAGES") == "1":
click.echo(" Retrying with --ignore-installed to bypass system packages...")
cmd.append("--ignore-installed")
_run(cmd)
elif result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
def _install_pre_commit_hooks(bin_dir: str) -> None: def _install_pre_commit_hooks(bin_dir: str) -> None:
@@ -40,7 +55,7 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
def _install_ansible_collections(bin_dir: str) -> None: def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist.""" """Install required Ansible Galaxy collections if requirements exist."""
galaxy = str(Path(bin_dir) / "ansible-galaxy") galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml") requirements = Path("ansible/requirements.yml")
if not requirements.exists(): if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.") click.echo(" ansible/requirements.yml not found — skipping collections.")
+260 -169
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -566,7 +566,8 @@ class TestVikunjaClient:
json={"done": True}, json={"done": True},
) )
def test_http_error_raises_api_error(self) -> None: @patch("devx.api_clients.time.sleep")
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
client = VikunjaClient("https://work.example.com", "tok") client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock() mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
+164 -10
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/check_test_speed.py.""" """Unit tests for devx.tools.check_test_speed."""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,10 +8,13 @@ from click.testing import CliRunner
from devx.tools.check_test_speed import ( from devx.tools.check_test_speed import (
DEFAULT_MAX_SECONDS, DEFAULT_MAX_SECONDS,
DEFAULT_MAX_SINGLE_SECONDS,
TEST_COMMAND, TEST_COMMAND,
check_per_test_speed,
check_speed, check_speed,
cli, cli,
parse_duration, parse_duration,
parse_per_test_durations,
run_tests, run_tests,
) )
@@ -23,12 +26,23 @@ class TestRunTests:
stdout, stderr = run_tests() stdout, stderr = run_tests()
assert stdout == "out" assert stdout == "out"
assert stderr == "err" assert stderr == "err"
mock_run.assert_called_once_with( mock_run.assert_called_once()
TEST_COMMAND, call_kwargs = mock_run.call_args
capture_output=True, assert call_kwargs.args[0] == TEST_COMMAND
text=True, assert call_kwargs.kwargs["capture_output"] is True
check=False, assert call_kwargs.kwargs["text"] is True
) assert call_kwargs.kwargs["check"] is False
env = call_kwargs.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
@patch("devx.tools.check_test_speed.subprocess.run")
def test_run_tests_preserves_existing_pytest_addopts(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
with patch.dict("os.environ", {"PYTEST_ADDOPTS": "-x"}, clear=False):
run_tests()
env = mock_run.call_args.kwargs["env"]
assert "--durations=0" in env["PYTEST_ADDOPTS"]
assert "-x" in env["PYTEST_ADDOPTS"]
class TestParseDuration: class TestParseDuration:
@@ -48,6 +62,38 @@ class TestParseDuration:
assert "Could not parse" in str(exc.value) assert "Could not parse" in str(exc.value)
class TestParsePerTestDurations:
def test_parses_call_lines(self) -> None:
output = "0.01s call tests/test_foo.py::test_bar\n"
durations = parse_per_test_durations(output)
assert len(durations) == 1
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
def test_parses_setup_and_teardown(self) -> None:
output = (
"0.02s setup tests/test_foo.py::test_bar\n"
"0.01s call tests/test_foo.py::test_bar\n"
"0.00s teardown tests/test_foo.py::test_bar\n"
)
durations = parse_per_test_durations(output)
assert len(durations) == 3
names = [d[0] for d in durations]
assert "tests/test_foo.py::test_bar" in names
def test_sorted_slowest_first(self) -> None:
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
durations = parse_per_test_durations(output)
assert durations[0][1] >= durations[1][1]
assert durations[0][1] == 0.50
def test_empty_output(self) -> None:
assert parse_per_test_durations("") == []
def test_ignores_non_duration_lines(self) -> None:
output = "Some random line\n234 passed in 0.70s\n"
assert parse_per_test_durations(output) == []
class TestCheckSpeed: class TestCheckSpeed:
def test_under_budget_passes(self) -> None: def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise check_speed(1.0, 2.0) # should not raise
@@ -64,6 +110,31 @@ class TestCheckSpeed:
assert "max allowed: 2.0s" in msg assert "max allowed: 2.0s" in msg
class TestCheckPerTestSpeed:
def test_no_violations_when_all_fast(self) -> None:
durations = [("test_a", 0.1), ("test_b", 0.2)]
assert check_per_test_speed(durations, 0.5) == []
def test_violation_when_test_exceeds_limit(self) -> None:
durations = [("test_slow", 0.6), ("test_fast", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 1
assert "test_slow" in violations[0]
assert "0.60s" in violations[0]
def test_multiple_violations(self) -> None:
durations = [("test_a", 0.7), ("test_b", 0.6), ("test_c", 0.1)]
violations = check_per_test_speed(durations, 0.5)
assert len(violations) == 2
def test_exact_limit_passes(self) -> None:
durations = [("test_a", 0.5)]
assert check_per_test_speed(durations, 0.5) == []
def test_empty_durations(self) -> None:
assert check_per_test_speed([], 0.5) == []
def test_main_module_block() -> None: def test_main_module_block() -> None:
import devx.tools.check_test_speed as cts import devx.tools.check_test_speed as cts
@@ -77,39 +148,71 @@ class TestMain:
@patch("devx.tools.check_test_speed.run_tests") @patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed") @patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_successful_run( def test_successful_run(
self, self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock, mock_check: MagicMock,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("stdout\n", "stderr\n") mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5 mock_parse.return_value = 1.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "1.50s" in result.output assert "1.50s" in result.output
assert "under 2.0s limit" in result.output assert "under 10.0s limit" in result.output
mock_run.assert_called_once() mock_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n") mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS) mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
mock_parse_per.assert_called_once()
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
@patch("devx.tools.check_test_speed.run_tests") @patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
def test_slow_tests_exit( def test_slow_total_exits(
self, self,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("out\n", "err\n") mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0 mock_parse.return_value = 15.0
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, []) result = runner.invoke(cli, [])
assert result.exit_code == 1 assert result.exit_code == 1
assert "too slow" in result.output.lower() assert "too slow" in result.output.lower()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_per_test_violation_exits(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
mock_parse_per.return_value = [("test_slow", 0.8)]
mock_check_per.return_value = ["Test 'test_slow' took 0.80s (limit: 0.5s)."]
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Per-test speed check FAILED" in result.output
assert "test_slow" in result.output
@patch("devx.tools.check_test_speed.run_tests") @patch("devx.tools.check_test_speed.run_tests")
def test_parse_failure_exits( def test_parse_failure_exits(
self, self,
@@ -125,16 +228,67 @@ class TestMain:
@patch("devx.tools.check_test_speed.run_tests") @patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration") @patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed") @patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_seconds( def test_custom_max_seconds(
self, self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock, mock_check: MagicMock,
mock_parse: MagicMock, mock_parse: MagicMock,
mock_run: MagicMock, mock_run: MagicMock,
) -> None: ) -> None:
mock_run.return_value = ("out\n", "err\n") mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5 mock_parse.return_value = 0.5
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"]) result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5) mock_check.assert_called_once_with(0.5, 1.5)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_disable_per_test_check(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "0"])
assert result.exit_code == 0
mock_parse_per.assert_not_called()
mock_check_per.assert_not_called()
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
@patch("devx.tools.check_test_speed.parse_per_test_durations")
@patch("devx.tools.check_test_speed.check_per_test_speed")
def test_custom_max_single_seconds(
self,
mock_check_per: MagicMock,
mock_parse_per: MagicMock,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 1.0
mock_parse_per.return_value = []
mock_check_per.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
assert result.exit_code == 0
mock_check_per.assert_called_once_with([], 1.0)
+23
View File
@@ -225,6 +225,29 @@ class TestMoleculeCommands:
mock_run.assert_called_once_with("devx.molecule.molecule_all", []) mock_run.assert_called_once_with("devx.molecule.molecule_all", [])
class TestNewCiCommands:
@patch("devx.cli._run_module")
def test_ci_distribute_files(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "distribute-files", "--", "--pattern", "*.py"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
@patch("devx.cli._run_module")
def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "integration-guard", "--", "-v"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.integration_guard", ["-v"])
class TestRunModule: class TestRunModule:
@patch("importlib.import_module") @patch("importlib.import_module")
def test_run_module_success(self, mock_import: MagicMock) -> None: def test_run_module_success(self, mock_import: MagicMock) -> None:
+154
View File
@@ -0,0 +1,154 @@
"""Unit tests for devx.ci.distribute_files."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.ci.distribute_files import (
DEFAULT_MAX_RUNNERS,
discover_files,
distribute,
files_for_runner,
main,
)
class TestDiscoverFiles:
def test_discovers_sorted(self, tmp_path: Path) -> None:
(tmp_path / "test_b.py").write_text("")
(tmp_path / "test_a.py").write_text("")
result = discover_files(str(tmp_path / "test_*.py"))
assert len(result) == 2
assert result[0].endswith("test_a.py")
assert result[1].endswith("test_b.py")
def test_no_matches(self, tmp_path: Path) -> None:
assert discover_files(str(tmp_path / "nonexistent-*.py")) == []
class TestDistribute:
def test_even_split(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
groups = distribute(files, 3)
assert len(groups) == 3
assert all(len(g) == 2 for g in groups)
def test_uneven_split(self) -> None:
files = [f"test_{i}.py" for i in range(5)]
groups = distribute(files, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_files(self) -> None:
files = ["test_a.py"]
groups = distribute(files, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty(self) -> None:
assert distribute([], 3) == [[], [], []]
class TestFilesForRunner:
def test_returns_correct_subset(self) -> None:
files = [f"test_{i}.py" for i in range(6)]
assert len(files_for_runner(files, 0, 3)) == 2
assert len(files_for_runner(files, 1, 3)) == 2
assert len(files_for_runner(files, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
with pytest.raises(Exception, match="out of range"):
files_for_runner(["a.py"], 5, 3)
class TestCli:
def test_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--pattern", str(tmp_path / "test_*.py"), "--max-runners", "3"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
assert "Runner 2:" in result.output
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
for i in range(3):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3"],
)
assert result.exit_code == 0
assert "test_0.py" in result.output
def test_github_env_writes_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
for i in range(2):
(tmp_path / f"test_{i}.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "2", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
[
"--pattern",
str(tmp_path / "test_*.py"),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "ASSIGNED_FILES=\n" in content
assert "SKIP=true" in content
def test_runner_index_zero_raises(self, tmp_path: Path) -> None:
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "0", "--max-runners", "3"],
)
assert result.exit_code != 0
def test_no_env_var_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
(tmp_path / "test.py").write_text("")
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "test_*.py"), "--runner-index", "1", "--max-runners", "3", "--github-env"],
)
assert result.exit_code != 0
def test_default_max_runners() -> None:
assert DEFAULT_MAX_RUNNERS == 3
def test_main_module_block() -> None:
import devx.ci.distribute_files as mod
assert hasattr(mod, "main")
+223 -5
View File
@@ -8,13 +8,19 @@ import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.molecule.distribute_molecule import ( from devx.molecule.distribute_molecule import (
DEFAULT_ROLES_ROOT,
MOLECULE_ROOT, MOLECULE_ROOT,
PLATFORMS, PLATFORMS,
MultiRoleTestPair,
TestPair, TestPair,
build_multi_role_pairs,
build_pairs, build_pairs,
cli, cli,
discover_multi_role_scenarios,
discover_scenarios, discover_scenarios,
distribute, distribute,
distribute_multi_role,
multi_role_pairs_for_runner,
pairs_for_runner, pairs_for_runner,
) )
@@ -157,10 +163,7 @@ class TestCli:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"]) result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "ubuntu-2204" in result.output assert "ubuntu-2604" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner from click.testing import CliRunner
@@ -192,7 +195,7 @@ class TestCli:
assert result.exit_code == 0 assert result.exit_code == 0
# Output should contain encoded pairs with platform info # Output should contain encoded pairs with platform info
assert "alpha|" in result.output assert "alpha|" in result.output
assert "ubuntu-2204" in result.output assert "ubuntu-2604" in result.output
class TestGithubEnv: class TestGithubEnv:
@@ -259,3 +262,218 @@ def test_main_module_block() -> None:
namespace = dict(dm.__dict__) namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace) exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"]) assert callable(namespace["cli"])
class TestDiscoverMultiRole:
def test_discovers_role_scenario_pairs(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
for scenario in ["default", "binary"]:
(roles / "gitea-runner" / "molecule" / scenario).mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "common").mkdir(parents=True)
(roles / "gitea-runner" / "molecule" / "_shared").mkdir(parents=True)
(roles / "docker-base" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("docker-base", "default") in result
assert ("gitea-runner", "default") in result
assert ("gitea-runner", "binary") in result
assert ("gitea-runner", "common") not in result
assert ("gitea-runner", "_shared") not in result
assert len(result) == 3
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_multi_role_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_roles_root_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Calling with no args uses DEFAULT_ROLES_ROOT which doesn't exist in tests."""
with pytest.raises(click.ClickException):
discover_multi_role_scenarios()
def test_default_roles_root_constant(self) -> None:
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
class TestMultiRoleTestPair:
def test_encode_roundtrip(self) -> None:
pair = MultiRoleTestPair(
"docker-base", "default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""}
)
encoded = pair.encode()
assert encoded == "docker-base|default|ubuntu-2204|ubuntu:22.04|"
decoded = MultiRoleTestPair.decode(encoded)
assert decoded.role == "docker-base"
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
class TestBuildMultiRolePairs:
def test_cross_product(self) -> None:
role_scenarios = [("role-a", "default"), ("role-b", "binary")]
platforms = [{"name": "p1", "image": "i1", "command": ""}]
pairs = build_multi_role_pairs(role_scenarios, platforms)
assert len(pairs) == 2
assert pairs[0].role == "role-a"
assert pairs[1].role == "role-b"
def test_default_platforms(self) -> None:
pairs = build_multi_role_pairs([("r", "s")])
assert len(pairs) == len(PLATFORMS)
class TestDistributeMultiRole:
def test_even_split(self) -> None:
pairs = [MultiRoleTestPair(f"r{i}", "s", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute_multi_role(pairs, 3)
assert all(len(g) == 2 for g in groups)
def test_out_of_range_raises(self) -> None:
pairs = [MultiRoleTestPair("r", "s", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException):
multi_role_pairs_for_runner(pairs, 5, 3)
class TestCliMultiRole:
def test_roles_root_list(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list"])
assert result.exit_code == 0
assert "role-a|default" in result.output
assert "role-b|binary" in result.output
def test_roles_root_runner_index(self, tmp_path: Path) -> None:
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
assert "role-a|default|" in result.output
def test_roles_root_github_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
["--roles-root", str(roles), "--runner-index", "1", "--max-runners", "3", "--github-env"],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=" in content
assert "SKIP=false" in content
def test_roles_root_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles),
"--runner-index",
"5",
"--max-runners",
"2",
"--github-env",
"--skip-if-excess",
],
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "SKIP=true" in content
def test_molecule_root_option(self, tmp_path: Path) -> None:
root = tmp_path / "custom-molecule"
(root / "alpha").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--molecule-root", str(root), "--list"])
assert result.exit_code == 0
assert "alpha" in result.output
def test_roles_root_list_platforms(self, tmp_path: Path) -> None:
"""--roles-root --list-platforms prints platforms."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2604" in result.output
def test_roles_root_no_runner_index_prints_groups(self, tmp_path: Path) -> None:
"""--roles-root without --runner-index prints all groups."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-b" / "molecule" / "binary").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--max-runners", "2"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
def test_platforms_file_overrides_default(self, tmp_path: Path) -> None:
"""--platforms-file loads custom platforms from JSON."""
import json
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
platforms_file = tmp_path / "platforms.json"
custom = [{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"}]
platforms_file.write_text(json.dumps(custom))
runner = CliRunner()
result = runner.invoke(
cli, ["--roles-root", str(roles), "--platforms-file", str(platforms_file), "--list-platforms"]
)
assert result.exit_code == 0
assert "custom-os" in result.output
assert "custom:latest" in result.output
def test_roles_root_skips_non_dir_role(self, tmp_path: Path) -> None:
"""Non-directory entries in roles root are skipped."""
roles = tmp_path / "roles"
roles.mkdir(parents=True)
(roles / "README.md").write_text("not a role")
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_non_dir_scenario(self, tmp_path: Path) -> None:
"""Non-directory entries in molecule dir are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule").mkdir(parents=True)
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "role-a" / "molecule" / "file.txt").write_text("not a scenario")
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_skips_role_without_molecule(self, tmp_path: Path) -> None:
"""Roles without a molecule/ directory are skipped."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
(roles / "no-molecule").mkdir(parents=True)
result = discover_multi_role_scenarios(roles)
assert ("role-a", "default") in result
assert len(result) == 1
def test_roles_root_runner_index_zero_raises(self, tmp_path: Path) -> None:
"""--roles-root --runner-index 0 should raise."""
roles = tmp_path / "roles"
(roles / "role-a" / "molecule" / "default").mkdir(parents=True)
runner = CliRunner()
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
assert result.exit_code != 0
assert "out of range" in result.output
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for devx.ci.integration_guard."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.integration_guard import cli
class TestCli:
def test_all_pass(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 0
assert "Integration tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "tests/integration/test_foo.py"])
assert result.exit_code == 1
assert "failed" in result.output
def test_junit_output_passed_to_pytest(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "--junitxml" in call_args
assert "junit-results/runner-1.xml" in call_args
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--", "-x", "-v", "--tb=short", "test_a.py", "test_b.py"],
)
assert result.exit_code == 0
call_args = mock_popen.call_args[0][0]
assert "-x" in call_args
assert "-v" in call_args
assert "test_a.py" in call_args
assert "test_b.py" in call_args
def test_keyboard_interrupt_kills_process(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep", side_effect=KeyboardInterrupt),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
mock_killpg.assert_called()
assert "cancelled" in result.output.lower()
def test_process_lookup_error_suppressed(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
def test_timeout_expired_kills_with_sigkill(self) -> None:
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "integration-tests (1)", "conclusion": "running"}]
return [
{"name": "integration-tests (0)", "conclusion": "running"},
{"name": "integration-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 1
# SIGKILL should have been called (second killpg call)
assert mock_killpg.call_count >= 2
def test_no_env_vars_runs_without_polling(self) -> None:
with (
patch.dict(os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None:
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
with (
patch.dict(
os.environ,
{"GITEA_URL": "https://gitea.example", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
assert "without cross-runner cancellation" in result.output
def test_invalid_repository_falls_back_to_default(self) -> None:
"""GITEA_REPOSITORY without '/' falls back to oblachno-oss/devx."""
with (
patch.dict(
os.environ,
{"GITEA_REPOSITORY": "invalid", "PATH": os.environ.get("PATH", "")},
clear=True,
),
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["--", "test_foo.py"])
assert result.exit_code == 0
def test_main_module_block() -> None:
import devx.ci.integration_guard as ig
with open(ig.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(ig.__dict__)
exec(compile(source, ig.__file__, "exec"), namespace)
assert callable(namespace["cli"])
+91
View File
@@ -0,0 +1,91 @@
"""Unit tests for devx.ci.merge_junit."""
from pathlib import Path
from xml.etree import ElementTree as ET
import pytest
from click.testing import CliRunner
from devx.ci.merge_junit import main, merge_files
def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
for i in range(tests):
tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
if i < failures:
ET.SubElement(tc, "failure", message="fail")
tree = ET.ElementTree(suite)
tree.write(path, encoding="UTF-8", xml_declaration=True)
class TestMergeFiles:
def test_merges_multiple_suites(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 5
assert total_failures == 1
assert merged.tag == "testsuites"
assert len(merged) == 2
def test_no_files_returns_empty(self, tmp_path: Path) -> None:
merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
assert total_tests == 0
assert total_failures == 0
assert merged.tag == "testsuites"
assert len(merged) == 0
def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
wrapper = ET.Element("testsuites")
suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
tree = ET.ElementTree(wrapper)
tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
assert total_tests == 4
assert total_failures == 2
class TestCli:
def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
_write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code == 0
assert out.exists()
tree = ET.parse(out)
root = tree.getroot()
assert root.get("tests") == "5"
assert root.get("failures") == "0"
def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
_write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
out = tmp_path / "merged.xml"
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
)
assert result.exit_code != 0
assert "failures" in result.output
def test_no_files_exits_zero(self, tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
)
assert result.exit_code == 0
assert "No JUnit" in result.output or "skipping" in result.output
def test_main_module_block() -> None:
import devx.ci.merge_junit as mod
assert hasattr(mod, "main")
+243
View File
@@ -5,8 +5,11 @@ from __future__ import annotations
import os import os
import subprocess # nosec B404 import subprocess # nosec B404
import time import time
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
import requests import requests
@@ -16,7 +19,10 @@ from devx.molecule.molecule_ci_guard import (
build_molecule_cmd, build_molecule_cmd,
cli, cli,
get_running_jobs, get_running_jobs,
parse_pair,
poll_for_other_failures, poll_for_other_failures,
resolve_role_dir,
write_junit_report,
) )
@@ -435,3 +441,240 @@ def test_main_module_block() -> None:
namespace = dict(mg.__dict__) namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace) exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"]) assert callable(namespace["cli"])
class TestParsePair:
def test_single_role_4_part(self) -> None:
role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|")
assert role == ""
assert scenario == "default"
assert name == "ubuntu-2204"
assert image == "ubuntu:22.04"
assert cmd == ""
def test_multi_role_5_part(self) -> None:
role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|")
assert role == "gitea-runner"
assert scenario == "default"
assert name == "ubuntu-2204"
assert image == "ubuntu:22.04"
assert cmd == ""
def test_multi_role_with_command(self) -> None:
role, scenario, name, image, cmd = parse_pair(
"docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
)
assert role == "docker-base"
assert scenario == "lifecycle"
assert cmd == "/usr/lib/systemd/systemd"
def test_invalid_pair_raises(self) -> None:
with pytest.raises(click.ClickException, match="Invalid pair format"):
parse_pair("only|two|parts")
def test_too_many_parts_raises(self) -> None:
with pytest.raises(click.ClickException, match="Invalid pair format"):
parse_pair("a|b|c|d|e|f")
class TestResolveRoleDir:
def test_multi_role_with_roles_root(self, tmp_path: Path) -> None:
roles_root = tmp_path / "ansible" / "roles"
roles_root.mkdir(parents=True)
result = resolve_role_dir("gitea-runner", roles_root, tmp_path)
assert result == roles_root / "gitea-runner"
def test_multi_role_default_roles_root(self, tmp_path: Path) -> None:
result = resolve_role_dir("docker-base", None, tmp_path)
assert result == tmp_path / "ansible" / "roles" / "docker-base"
def test_single_role_uses_default(self, tmp_path: Path) -> None:
result = resolve_role_dir("", None, tmp_path)
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
class TestWriteJunitReport:
def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
output = str(tmp_path / "junit-results" / "runner-1.xml")
testcases = [
{"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
{"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
]
write_junit_report(output, testcases, 1)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "2"
assert root.get("failures") == "0"
assert len(root) == 2
def test_writes_report_with_failures(self, tmp_path: Path) -> None:
output = str(tmp_path / "runner-2.xml")
testcases = [
{"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
]
write_junit_report(output, testcases, 2)
tree = ET.parse(output)
root = tree.getroot()
assert root.get("tests") == "1"
assert root.get("failures") == "1"
failure = root[0][0]
assert failure.tag == "failure"
assert failure.text == "Exit code: 1"
def test_creates_parent_directory(self, tmp_path: Path) -> None:
output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
write_junit_report(output, [], 0)
assert Path(output).exists()
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"],
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_junit_output_written(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 0
assert Path(junit_path).exists()
def test_junit_output_on_failure(self, tmp_path: Path) -> None:
from click.testing import CliRunner
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
assert tree.getroot().get("failures") == "1"
def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
"""JUnit report is written when a runner is cancelled by another runner's failure."""
from click.testing import CliRunner
real_sleep = time.sleep
roles_root = tmp_path / "ansible" / "roles"
(roles_root / "gitea-runner").mkdir(parents=True)
junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg"),
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(
cli,
[
"--roles-root",
str(roles_root),
"--junit-output",
junit_path,
"gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
],
)
assert result.exit_code == 1
assert Path(junit_path).exists()
tree = ET.parse(junit_path)
root = tree.getroot()
assert root.get("failures") == "1"
# The failure message should mention cancellation
failure = root[0][0]
assert "Cancelled" in (failure.text or "")
+103 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.notify_failure import main from devx.ci.notify_failure import _configure_tea_login, main
from devx.gitea_cli import TeaCLIError from devx.gitea_cli import TeaCLIError
@@ -114,3 +114,105 @@ class TestNotifyFailure:
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with tea not installed skips login and still creates issue."""
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 60, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "issue #60" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue."""
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
class TestConfigureTeaLogin:
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no token prints skip message and returns."""
_configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no tea binary prints skip message and returns."""
_configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_configures_tea(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
) -> None:
"""--auto-login calls tea login add and default."""
mock_run = MagicMock()
mock_run.returncode = 0
mock_run.stdout = ""
mock_subprocess.return_value = mock_run
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 61, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "issue #61" in result.output
# tea login add was called
assert mock_subprocess.call_count >= 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_skips_if_already_configured(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
) -> None:
"""--auto-login skips tea login add if login already exists."""
mock_list = MagicMock()
mock_list.returncode = 0
mock_list.stdout = "devx https://git.example.com"
mock_subprocess.return_value = mock_list
mock_tea = MagicMock()
mock_tea.list_labels.return_value = []
mock_tea.create_issue.return_value = {"index": 62, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
)
assert result.exit_code == 0
assert "already configured" in result.output
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for devx.opentofu."""
from __future__ import annotations
import json
from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import MagicMock, patch
import pytest
from devx.opentofu import get_tofu_output, get_tofu_vm_field, get_tofu_vm_ip
class TestGetTofuOutput:
@patch("devx.opentofu.subprocess.run")
def test_returns_parsed_json(self, mock_run: MagicMock) -> None:
payload = {"staging": {"ipv4": "1.2.3.4"}}
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps(payload),
stderr="",
)
result = get_tofu_output("customer_vms", cwd="/tmp/tofu/staging")
assert result == payload
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["tofu", "output", "-json", "customer_vms"]
assert call_kwargs.kwargs["cwd"] == "/tmp/tofu/staging"
assert call_kwargs.kwargs["env"] is None
@patch("devx.opentofu.subprocess.run")
def test_with_env(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout='{"staging": {"ipv4": "5.6.7.8"}}',
stderr="",
)
env = {"HCLOUD_TOKEN": "secret"}
result = get_tofu_output("obs", cwd=Path("/tmp"), env=env)
assert result == {"staging": {"ipv4": "5.6.7.8"}}
assert mock_run.call_args.kwargs["env"] == env
@patch("devx.opentofu.subprocess.run")
def test_no_cwd(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout='{"a": 1}',
stderr="",
)
result = get_tofu_output("x")
assert result == {"a": 1}
assert mock_run.call_args.kwargs["cwd"] is None
@patch("devx.opentofu.subprocess.run")
def test_pathlib_cwd(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout="{}",
stderr="",
)
get_tofu_output("x", cwd=Path("/some/path"))
assert mock_run.call_args.kwargs["cwd"] == "/some/path"
@patch("devx.opentofu.subprocess.run")
def test_failure_raises_runtime_error(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=1,
stdout="",
stderr="Error: module not found",
)
with pytest.raises(RuntimeError, match="tofu output failed"):
get_tofu_output("x", cwd="/tmp")
@patch("devx.opentofu.subprocess.run")
def test_invalid_json_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "x"],
returncode=0,
stdout="not json",
stderr="",
)
with pytest.raises(json.JSONDecodeError):
get_tofu_output("x")
class TestGetTofuVmIp:
@patch("devx.opentofu.subprocess.run")
def test_returns_ipv4(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"oblachno": {"ipv4": "10.0.0.1"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "oblachno", cwd="/tmp")
assert ip == "10.0.0.1"
@patch("devx.opentofu.subprocess.run")
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"other": {"ipv4": "10.0.0.2"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "missing", cwd="/tmp")
assert ip == ""
@patch("devx.opentofu.subprocess.run")
def test_missing_ip_field_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "customer_vms"],
returncode=0,
stdout=json.dumps({"vm1": {"name": "test"}}),
stderr="",
)
ip = get_tofu_vm_ip("customer_vms", "vm1", cwd="/tmp")
assert ip == ""
@patch("devx.opentofu.subprocess.run")
def test_custom_ip_field(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "vms"],
returncode=0,
stdout=json.dumps({"vm1": {"address": "192.168.1.1"}}),
stderr="",
)
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp", ip_field="address")
assert ip == "192.168.1.1"
@patch("devx.opentofu.subprocess.run")
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "vms"],
returncode=0,
stdout='["not", "a", "dict"]',
stderr="",
)
ip = get_tofu_vm_ip("vms", "vm1", cwd="/tmp")
assert ip == ""
class TestGetTofuVmField:
@patch("devx.opentofu.subprocess.run")
def test_returns_field_value(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"staging": {"volume_linux_device": "/dev/sda1"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
assert val == "/dev/sda1"
@patch("devx.opentofu.subprocess.run")
def test_missing_field_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"staging": {"ipv4": "1.2.3.4"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "volume_linux_device", cwd="/tmp")
assert val == ""
@patch("devx.opentofu.subprocess.run")
def test_missing_vm_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout=json.dumps({"prod": {"x": "y"}}),
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
assert val == ""
@patch("devx.opentofu.subprocess.run")
def test_non_dict_output_returns_empty(self, mock_run: MagicMock) -> None:
mock_run.return_value = CompletedProcess(
args=["tofu", "output", "-json", "obs"],
returncode=0,
stdout='"a string"',
stderr="",
)
val = get_tofu_vm_field("obs", "staging", "x", cwd="/tmp")
assert val == ""
+40 -7
View File
@@ -1,11 +1,13 @@
"""Unit tests for scripts/ci/platforms.py.""" """Unit tests for devx.molecule.platforms."""
from devx.molecule.platforms import PLATFORMS import json
from devx.molecule.platforms import PLATFORMS, load_platforms
class TestPlatforms: class TestPlatforms:
def test_platforms_not_empty(self) -> None: def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4 assert len(PLATFORMS) >= 1
def test_each_platform_has_required_keys(self) -> None: def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS: for p in PLATFORMS:
@@ -17,9 +19,40 @@ class TestPlatforms:
names = [p["name"] for p in PLATFORMS] names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names)) assert len(names) == len(set(names))
def test_platforms_use_sleep_infinity(self) -> None:
"""All default platforms must use sleep infinity, not systemd."""
for p in PLATFORMS:
assert p["command"] == "sleep infinity", f"Platform {p['name']} uses {p['command']}"
def test_known_platforms_present(self) -> None: def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS} names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names assert "ubuntu-2604" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" in names class TestLoadPlatforms:
def test_load_platforms_default(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms with no file returns PLATFORMS."""
result = load_platforms(None)
assert result == PLATFORMS
def test_load_platforms_from_file(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms reads custom platforms from JSON file."""
custom = [
{"name": "custom-os", "image": "custom:latest", "command": "sleep infinity"},
]
f = tmp_path / "platforms.json"
f.write_text(json.dumps(custom))
result = load_platforms(f)
assert result == custom
def test_load_platforms_missing_file_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms falls back to PLATFORMS when file doesn't exist."""
result = load_platforms(tmp_path / "nonexistent.json")
assert result == PLATFORMS
def test_load_platforms_empty_list_falls_back(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""load_platforms falls back to PLATFORMS when file has empty list."""
f = tmp_path / "platforms.json"
f.write_text("[]")
result = load_platforms(f)
assert result == PLATFORMS
+17
View File
@@ -300,3 +300,20 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "Release creation failed" in result.output assert "Release creation failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
def test_skip_build_skips_build_and_publish(
self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
assert "skip" in result.output.lower()
mock_build.assert_not_called()
mock_tea.create_release.assert_called_once()
+64
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -223,3 +224,66 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0 assert result.exit_code == 0
mock_update.assert_not_called() mock_update.assert_not_called()
def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --retries 3, first attempt fails but second succeeds."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
import subprocess
call_count = [0]
def side_effect(*args: Any, **kwargs: Any) -> Any:
call_count[0] += 1
# First call (git fetch) fails, rest succeed
if call_count[0] == 1:
raise subprocess.CalledProcessError(1, "git fetch")
return MagicMock(returncode=0, stdout="", stderr="")
runner = CliRunner()
with (
patch("subprocess.run", side_effect=side_effect),
patch("devx.ci.push_badges.update_readme_with_badge_sha"),
patch("time.sleep"),
):
result = runner.invoke(
push_badges.main,
["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"],
)
assert result.exit_code == 0
def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --retries 2, all attempts fail and exit code is non-zero."""
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with (
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
patch("time.sleep"),
):
result = runner.invoke(
push_badges.main,
["--no-readme-update", "--retries", "2"],
)
assert result.exit_code != 0
assert "failed after 2" in result.output
def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without --retries, only one attempt is made (no retry on failure)."""
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with (
patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")),
patch("time.sleep") as mock_sleep,
):
result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0
mock_sleep.assert_not_called()
+8 -7
View File
@@ -53,14 +53,14 @@ class TestRunCmd:
class TestGetLatestTag: class TestGetLatestTag:
@patch("devx.ci.release.run_cmd") @patch("devx.ci._shared.subprocess.run")
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") mock_run.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
assert get_latest_tag() == "v0.1.0" assert get_latest_tag() == "v0.1.0"
@patch("devx.ci.release.run_cmd") @patch("devx.ci._shared.subprocess.run")
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: def test_no_tags_returns_empty(self, mock_run: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") mock_run.return_value = MagicMock(returncode=1, stdout="")
assert get_latest_tag() == "" assert get_latest_tag() == ""
@@ -889,11 +889,12 @@ class TestMain:
assert "master" in result.output assert "master" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.get_latest_tag", return_value="v0.5.0")
@patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.has_user_facing_changes", return_value=False) @patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_dry_run_on_non_master_warns( def test_dry_run_on_non_master_warns(
self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock self, mock_run_cmd: MagicMock, mock_uf: MagicMock, mock_vtc: MagicMock, mock_glt: MagicMock
) -> None: ) -> None:
"""Dry-run mode should not fail on non-master branches.""" """Dry-run mode should not fail on non-master branches."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
+38 -8
View File
@@ -1,5 +1,6 @@
"""Unit tests for devx.tools.setup.""" """Unit tests for devx.tools.setup."""
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -32,20 +33,48 @@ class TestRun:
class TestInstallPythonDeps: class TestInstallPythonDeps:
@patch("devx.tools.setup._run") @patch("devx.tools.setup.subprocess.run")
def test_install_dev(self, mock_run: MagicMock) -> None: def test_install_dev(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev") _install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"]) mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
@patch("devx.tools.setup._run") @patch("devx.tools.setup.subprocess.run")
def test_install_ci(self, mock_run: MagicMock) -> None: def test_install_ci(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci") _install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"]) mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
@patch("devx.tools.setup.subprocess.run")
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
@patch("devx.tools.setup.subprocess.run")
def test_install_with_break_system_packages(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with(
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages"], check=False
)
@patch("devx.tools.setup._run") @patch("devx.tools.setup._run")
def test_install_custom_extras(self, mock_run: MagicMock) -> None: @patch("devx.tools.setup.subprocess.run")
_install_python_deps(".venv/bin", "ci,lint") def test_install_retry_with_ignore_installed(self, mock_subprocess: MagicMock, mock_run: MagicMock) -> None:
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"]) mock_subprocess.return_value = MagicMock(returncode=1)
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with(
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages", "--ignore-installed"]
)
@patch("devx.tools.setup.subprocess.run")
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1)
with pytest.raises(subprocess.CalledProcessError):
_install_python_deps(".venv/bin", "ci")
class TestInstallPreCommitHooks: class TestInstallPreCommitHooks:
@@ -60,8 +89,9 @@ class TestInstallPreCommitHooks:
class TestInstallAnsibleCollections: class TestInstallAnsibleCollections:
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run") @patch("devx.tools.setup._run")
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None: def test_installs_from_requirements(self, mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path) -> None:
req = tmp_path / "ansible" / "requirements.yml" req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True) req.parent.mkdir(parents=True)
req.write_text("collections: []") req.write_text("collections: []")
+278
View File
@@ -0,0 +1,278 @@
"""Unit tests for devx.molecule.start_docker."""
import os
from unittest.mock import MagicMock, mock_open, patch
from click.testing import CliRunner
from devx.molecule.start_docker import (
DOCKER_SOCK,
_diagnose_socket,
is_docker_ready,
main,
start_docker_daemon,
)
class TestIsDockerReady:
@patch("devx.molecule.start_docker.subprocess.run")
def test_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.args[0] == ["docker", "info"]
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == f"unix://{DOCKER_SOCK}"
@patch("devx.molecule.start_docker.subprocess.run")
def test_not_ready(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1)
assert is_docker_ready() is False
@patch("devx.molecule.start_docker.subprocess.run")
def test_uses_docker_host_env(self, mock_run: MagicMock) -> None:
"""Should check the socket specified by DOCKER_HOST env var."""
mock_run.return_value = MagicMock(returncode=0)
rootless = "unix:///run/user/999/docker.sock"
with patch.dict("os.environ", {"DOCKER_HOST": rootless}, clear=False):
assert is_docker_ready() is True
call_kwargs = mock_run.call_args
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
class TestDiagnoseSocket:
@patch("devx.molecule.start_docker.os.stat")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_exists(self, mock_run: MagicMock, mock_exists: MagicMock, mock_stat: MagicMock) -> None:
mock_stat.return_value = MagicMock(st_mode=0o660, st_uid=0, st_gid=0)
mock_run.side_effect = [
MagicMock(stdout="/dev/sda1 /var/lib/docker ext4\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(
stdout="Server Version: 29.5.2\nStorage Driver: overlay2\nDocker Root Dir: /var/lib/docker\n",
returncode=0,
text="",
),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.subprocess.run")
def test_socket_missing(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(stdout="proc on /proc type proc\n", returncode=0, text=""),
MagicMock(stdout="default\n", returncode=0, text=""),
MagicMock(stdout="", stderr="Cannot connect", returncode=1, text=""),
]
_diagnose_socket()
mock_exists.assert_called_with(DOCKER_SOCK)
class TestStartDockerDaemon:
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_host_socket_available(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None:
"""Should return immediately if host Docker is available."""
assert start_docker_daemon(timeout=5) is True
mock_ready.assert_called_once()
mock_diag.assert_called_once()
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_rootless_socket_available(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should use rootless socket if host socket fails."""
# First check (host) fails, second check (rootless) succeeds
mock_ready.side_effect = [False, True]
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_found(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should find rootless socket at a different UID via glob scan."""
# Host fails, own rootless fails, alt rootless succeeds
mock_ready.side_effect = [False, False, True]
alt_sock = "/run/user/999/docker.sock"
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
assert start_docker_daemon(timeout=5) is True
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
@patch("devx.molecule.start_docker.is_docker_ready")
def test_alt_rootless_socket_skips_own(
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
) -> None:
"""Should skip the own rootless socket in glob scan (already tried)."""
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
mock_ready.side_effect = [False, False, False, False, False, False]
own_sock = f"/run/user/{os.getuid()}/docker.sock"
alt_sock = "/run/user/999/docker.sock"
with (
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
patch("devx.molecule.start_docker.time.sleep"),
patch("devx.molecule.start_docker.subprocess.Popen"),
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
patch("builtins.open", mock_open(read_data="err")),
):
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_starts_local_daemon(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local daemon starts
mock_ready.side_effect = [False, False, False, False, True]
assert start_docker_daemon(timeout=5) is True
mock_popen.assert_called_once()
popen_args = mock_popen.call_args.args[0]
assert "dockerd" in popen_args
assert "--storage-driver" in popen_args
assert "vfs" in popen_args
assert "-H" in popen_args
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_after_timeout(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", mock_open(read_data="dockerd error log")):
assert start_docker_daemon(timeout=3) is False
mock_popen.assert_called_once()
assert mock_sleep.call_count == 3
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_fails_log_read_error(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
"""Should handle log read errors gracefully."""
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
with patch("builtins.open", side_effect=OSError("permission denied")):
assert start_docker_daemon(timeout=2) is False
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
@patch("devx.molecule.start_docker.is_docker_ready")
@patch("devx.molecule.start_docker.time.sleep")
@patch("devx.molecule.start_docker.subprocess.Popen")
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
def test_local_daemon_ready_on_first_check(
self,
mock_ntf: MagicMock,
mock_popen: MagicMock,
mock_sleep: MagicMock,
mock_ready: MagicMock,
mock_exists: MagicMock,
mock_diag: MagicMock,
mock_glob: MagicMock,
) -> None:
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
# Host fails, rootless doesn't exist, local ready on first loop check
mock_ready.side_effect = [False, False, True]
assert start_docker_daemon(timeout=5) is True
assert mock_popen.call_count == 1
assert mock_sleep.call_count == 1
@patch("devx.molecule.start_docker._diagnose_socket")
@patch("devx.molecule.start_docker.os.environ")
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
def test_sets_docker_host(
self,
mock_ready: MagicMock,
mock_environ: MagicMock,
mock_diag: MagicMock,
) -> None:
"""DOCKER_HOST must be set so molecule connects to correct socket."""
start_docker_daemon(timeout=5)
mock_environ.__setitem__.assert_called_with("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
class TestMain:
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_success(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=False)
def test_failure(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 1
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_custom_timeout_flag(self, mock_start: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--timeout", "60"])
assert result.exit_code == 0
mock_start.assert_called_once_with(60)
@patch("devx.molecule.start_docker.os.environ.get")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
mock_get.side_effect = lambda key, default="": (
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
)
with patch("builtins.open", mock_open()) as mock_file:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
mock_file.assert_called_with("/tmp/github_env", "a")
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
def test_no_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
"""Should not crash when GITHUB_ENV is not set."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0