diff --git a/AGENTS.md b/AGENTS.md index 695f3c7..ed2a62b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,6 @@ src/devx/ └── molecule/ # Optional molecule testing helpers (for Ansible projects) ├── discover_runners.py # Dynamic Gitea runner discovery ├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role) - ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root) ├── molecule_all.py # Run all molecule scenarios locally ├── start_docker.py # Ensure Docker daemon is running for molecule tests └── platforms.py # Supported molecule platforms diff --git a/README.md b/README.md index adf7f42..7ee34ce 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,6 @@ python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3 python -m devx.molecule.distribute_molecule --list # list all scenarios python -m devx.molecule.distribute_molecule --list-platforms # list platforms -# Run molecule tests with cross-runner fail-fast -python -m devx.molecule.molecule_ci_guard pair1 pair2 -python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 - # Run all molecule scenarios locally (sequential) python -m devx.molecule.molecule_all python -m devx.molecule.molecule_all --bin .venv/bin @@ -303,7 +299,6 @@ devx --version | `devx molecule all` | Run all molecule scenarios on all supported platforms | | `devx molecule discover-runners` | Discover available Gitea Actions runners | | `devx molecule distribute` | Distribute molecule test pairs across parallel runners | -| `devx molecule guard` | Run molecule tests with CI failure polling | See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands) in the wiki for full command documentation with examples. diff --git a/docs/index.md b/docs/index.md index dc46b5e..9f9cdc0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,8 +104,8 @@ devx is a self-contained Python package under `src/devx/`: - **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed, configure_repo, generate_badges, generate_cliff_config, install_checkmake - **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible - roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners, - start_docker, platforms + roles: distribute_molecule, molecule_all, discover_runners, start_docker, + platforms See [Architecture](Architecture) for the full package structure, module descriptions, design principles, and data flow diagrams. @@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups: - `devx ci ` — CI/CD automation (17 commands) - `devx tools ` — Developer tools (9 commands) -- `devx molecule ` — Molecule testing (4 commands, optional) +- `devx molecule ` — Molecule testing (3 commands, optional) See [CLI Commands](CLI-Commands) for full command documentation with examples. diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 18572e5..b124544 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -50,7 +50,6 @@ src/devx/ ├── __init__.py ├── discover_runners.py # Dynamic Gitea runner discovery ├── distribute_molecule.py # Distribute scenarios across runners - ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast ├── molecule_all.py # Run all molecule scenarios locally ├── start_docker.py # Ensure Docker is available for molecule └── platforms.py # Supported molecule platforms @@ -300,9 +299,9 @@ Distributes files matching a glob pattern across N parallel runners ### `integration_guard.py` -Runs pytest with the same cross-runner failure detection 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. +Runs pytest with cross-runner failure detection. A background thread polls +the Gitea API. If any other integration-tests matrix runner reports failure, +the current pytest subprocess is killed and this runner exits early. ## Developer tools (`devx.tools`) @@ -382,13 +381,6 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role repositories, `--list` to list scenarios, and `--list-platforms` to list platforms. -### `molecule_ci_guard.py` - -Runs molecule tests sequentially while polling the Gitea API for other runner -failures. If any other molecule matrix runner reports failure, the current -molecule subprocess is killed and this runner exits early. Supports both -single-role (4-part) and multi-role (5-part) pair encoding. - ### `molecule_all.py` Runs all molecule scenarios on all supported OS platforms sequentially. diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 094dbbc..ef2dbde 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -478,15 +478,6 @@ python -m devx.molecule.distribute_molecule --list python -m devx.molecule.distribute_molecule --list-platforms ``` -### `molecule_ci_guard.py` - -Runs molecule tests sequentially while polling the Gitea API for other runner -failures. Aborts early if another runner fails the same job. - -```bash -python -m devx.molecule.molecule_ci_guard [--roles-root ] pair1 pair2 ... -``` - ### `validate_commit_msg.py` Validates commit messages. On feature branches: conventional commits only diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 453a39c..3fdf9d1 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -531,29 +531,3 @@ Options: - `--list-platforms` — list all platforms, one per line - `--roles-root ` — roles root directory for multi-role repos (default: `ansible/roles`) - -### `devx molecule guard` - -Run molecule tests sequentially with CI failure polling. A background thread -polls the Gitea API. If any other molecule matrix runner reports failure, the -current molecule subprocess is killed and this runner exits early with code 1. - -```bash -devx molecule guard pair1 pair2 pair3 -devx molecule guard --roles-root ansible/roles pair1 pair2 -``` - -Each pair is encoded as: -- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command` -- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command` - -Options: -- `--roles-root ` — roles root directory for multi-role repos - -Environment variables: -- `GITEA_URL` — base URL of the Gitea instance -- `CI_GITEA_TOKEN` — API token with repo access -- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`) -- `JOB_NAME` — base job name (`GITHUB_JOB`) -- `MATRIX_INDEX` — current matrix index (runner-index) -- `GITEA_REPOSITORY` — repository in `owner/repo` format diff --git a/src/devx/ci/doc_coverage.py b/src/devx/ci/doc_coverage.py index e3ccbf7..2063c7b 100644 --- a/src/devx/ci/doc_coverage.py +++ b/src/devx/ci/doc_coverage.py @@ -52,7 +52,6 @@ REQUIRED_SCRIPTS = [ "detect_release_commit.py", "push_badges.py", "distribute_molecule.py", - "molecule_ci_guard.py", "validate_commit_msg.py", ] diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py index 5fba8f5..9d1f952 100644 --- a/src/devx/ci/integration_guard.py +++ b/src/devx/ci/integration_guard.py @@ -1,10 +1,9 @@ #!/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. +Wraps ``pytest`` with Gitea API polling. If any other integration-tests +matrix runner reports failure, the current pytest subprocess is killed +and this runner exits early with code 1. Usage:: @@ -35,17 +34,62 @@ import threading import time import click +import requests from devx.config import REPO_NAME, REPO_OWNER from devx.i18n import _ -from devx.molecule.molecule_ci_guard import ( - poll_for_other_failures, -) from devx.tokens import get_ci_token POLL_INTERVAL = 10 +def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]: + """Return jobs for the given workflow run.""" + url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs" + headers = {"Authorization": f"token {token}"} + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + return data.get("jobs", []) + + +def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool: + """Return True if any other matrix job has failed.""" + for job in jobs: + name = job.get("name", "") + if not name.startswith(current_job_name): + continue + if name == f"{current_job_name} ({current_index})" or name == current_job_name: + continue + if job.get("conclusion") == "failure": + return True + return False + + +def poll_for_other_failures( + gitea_url: str, + owner: str, + repo: str, + token: str, + run_id: int, + job_name: str, + current_index: int, + stop_event: threading.Event, + failed_event: threading.Event, +) -> None: + """Background thread: poll API and signal if another runner fails.""" + while not stop_event.is_set(): + try: + jobs = get_running_jobs(gitea_url, owner, repo, token, run_id) + if any_other_runner_failed(jobs, job_name, current_index): + click.echo(_("Another runner failed. Stopping this runner early.")) + failed_event.set() + return + except requests.RequestException as exc: + click.echo(_("API poll warning: {exc}", exc=exc)) + stop_event.wait(POLL_INTERVAL) + + @click.command(context_settings={"ignore_unknown_options": True}) @click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True) def cli(pytest_args: tuple[str, ...]) -> None: diff --git a/src/devx/cli.py b/src/devx/cli.py index 1cb3e08..ac02e4c 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -259,13 +259,6 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None: _run_module("devx.molecule.discover_runners", list(args)) -@molecule.command("guard") -@click.argument("args", nargs=-1) -def molecule_guard(args: tuple[str, ...]) -> None: - """Run molecule tests sequentially with CI failure polling.""" - _run_module("devx.molecule.molecule_ci_guard", list(args)) - - @molecule.command("all") @click.argument("args", nargs=-1) def molecule_all(args: tuple[str, ...]) -> None: diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py deleted file mode 100644 index 56f999f..0000000 --- a/src/devx/molecule/molecule_ci_guard.py +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env python3 -"""Run molecule tests sequentially while polling Gitea for other runner failures. - -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 -Docker networks, so parallel execution within a single runner is unsafe). - -A background thread polls the Gitea API. If any other molecule matrix runner -reports failure, the current molecule subprocess is killed and this runner -exits early with code 1. - -Usage:: - - # Single-role - python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ... - # Multi-role - python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ... - -Environment variables: - GITEA_URL Base URL of the Gitea instance. - CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy). - RUN_ID Workflow run ID (GITHUB_RUN_ID). - JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-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 -from pathlib import Path - -import click -import requests - -from devx.config import REPO_NAME, REPO_OWNER -from devx.i18n import _ -from devx.tokens import get_ci_token - -POLL_INTERVAL = 10 - - -def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]: - """Return jobs for the given workflow run.""" - url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs" - headers = {"Authorization": f"token {token}"} - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - data = response.json() - return data.get("jobs", []) - - -def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool: - """Return True if any other molecule matrix job has failed.""" - for job in jobs: - name = job.get("name", "") - if not name.startswith(current_job_name): - continue - if name == f"{current_job_name} ({current_index})" or name == current_job_name: - continue - if job.get("conclusion") == "failure": - return True - return False - - -def poll_for_other_failures( - gitea_url: str, - owner: str, - repo: str, - token: str, - run_id: int, - job_name: str, - current_index: int, - stop_event: threading.Event, - failed_event: threading.Event, -) -> None: - """Background thread: poll API and signal if another runner fails.""" - while not stop_event.is_set(): - try: - jobs = get_running_jobs(gitea_url, owner, repo, token, run_id) - if any_other_runner_failed(jobs, job_name, current_index): - click.echo(_("Another molecule runner failed. Stopping this runner early.")) - failed_event.set() - return - except requests.RequestException as exc: - click.echo(_("API poll warning: {exc}", exc=exc)) - stop_event.wait(POLL_INTERVAL) - - -def build_molecule_cmd(scenario: str) -> list[str]: - """Build the molecule command for a scenario.""" - cmd = ["molecule", "test"] - if scenario != "default": - cmd.extend(["-s", scenario]) - 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]: - """Build environment for a single molecule pair.""" - _role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair) - env = base_env.copy() - # Append runner index to platform name when running in CI matrix to avoid - # Docker container name conflicts when multiple runners share the same Docker host. - matrix_index = env.get("MATRIX_INDEX") - if matrix_index: - platform_name = f"{platform_name}-r{matrix_index}" - env["MOLECULE_PLATFORM_NAME"] = platform_name - env["MOLECULE_PLATFORM_IMAGE"] = platform_image - if platform_command: - env["MOLECULE_PLATFORM_COMMAND"] = platform_command - elif "MOLECULE_PLATFORM_COMMAND" in env: - del env["MOLECULE_PLATFORM_COMMAND"] - 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 - - -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, auto-discovers the first role with a molecule/ - subdirectory under ``repo_root/ansible/roles/``. - """ - if role: - if roles_root is None: - roles_root = repo_root / "ansible" / "roles" - return roles_root / role - roles_dir = repo_root / "ansible" / "roles" - if roles_dir.is_dir(): - role_dirs = sorted(d for d in roles_dir.iterdir() if (d / "molecule").is_dir()) - if role_dirs: - return role_dirs[0] - return roles_dir / "role" # will produce a clear "not found" error - - -@click.command() -@click.argument("pairs", nargs=-1, required=True) -@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, ...], roles_root: Path | None) -> None: - """Run molecule pairs sequentially, stop if another CI runner fails.""" - gitea_url = os.environ.get("GITEA_URL", "") - try: - token = get_ci_token() - except click.ClickException: - token = None - run_id = int(os.environ.get("RUN_ID", "0")) - job_name = os.environ.get("JOB_NAME", "molecule-tests") - current_index = int(os.environ.get("MATRIX_INDEX", "0")) - repository = os.environ.get("GITEA_REPOSITORY", "") - owner, _sep, repo = repository.partition("/") - if not owner or not repo: - owner, repo = REPO_OWNER, REPO_NAME - - if not all([gitea_url, token, run_id]): - click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) - - # When devx is installed as a pip package, __file__ resolves to the - # site-packages directory, not the repo root. Use GITHUB_WORKSPACE - # (set by Gitea Actions) or cwd as the repo root. - repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve() - - base_env = os.environ.copy() - base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock") - base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1") - - 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() - - try: - for pair in pairs: - if failed_event.is_set(): - sys.exit(1) - - role, scenario, platform_name, _img, _cmd = parse_pair(pair) - click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)) - - cmd = build_molecule_cmd(scenario) - env = build_env_for_pair(pair, base_env) - cwd = resolve_role_dir(role, roles_root, repo_root) - - process = subprocess.Popen( # nosec B603 - cmd, - cwd=str(cwd), - env=env, - 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() - # Clean up containers left behind by the killed test. - click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) - destroy_cmd = ["molecule", "destroy"] - if scenario != "default": - destroy_cmd.extend(["-s", scenario]) - with contextlib.suppress(subprocess.SubprocessError, OSError): - subprocess.run( # nosec B603, B607 - destroy_cmd, - cwd=str(cwd), - env=env, - check=False, - capture_output=True, - timeout=120, - ) - sys.exit(1) - time.sleep(1) - except KeyboardInterrupt: - with contextlib.suppress(ProcessLookupError): - os.killpg(os.getpgid(process.pid), signal.SIGTERM) - process.wait() - # Clean up containers left behind by the interrupted test. - click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) - destroy_cmd = ["molecule", "destroy"] - if scenario != "default": - destroy_cmd.extend(["-s", scenario]) - with contextlib.suppress(subprocess.SubprocessError, OSError): - subprocess.run( # nosec B603, B607 - destroy_cmd, - cwd=str(cwd), - env=env, - check=False, - capture_output=True, - timeout=120, - ) - sys.exit(1) - - rc = process.returncode - - if rc != 0: - click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)) - # Run molecule destroy to clean up containers left behind by the - # failed test. Without this, containers stay running and accumulate - # on the runner, consuming disk/memory and degrading CI performance. - click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario)) - destroy_cmd = ["molecule", "destroy"] - if scenario != "default": - destroy_cmd.extend(["-s", scenario]) - with contextlib.suppress(subprocess.SubprocessError, OSError): - subprocess.run( # nosec B603, B607 - destroy_cmd, - cwd=str(cwd), - env=env, - check=False, - capture_output=True, - timeout=120, - ) - sys.exit(rc) - - click.echo(_("PASSED: {pair}", pair=pair)) - - # Prune Docker data between scenarios to prevent disk exhaustion - # in Docker-in-Docker molecule containers (each scenario pulls - # hundreds of MB of images that accumulate across pairs). - with contextlib.suppress(subprocess.SubprocessError, OSError): - subprocess.run( # nosec B603, B607 - ["docker", "system", "prune", "-af", "--volumes"], - check=False, - capture_output=True, - timeout=60, - ) - - click.echo(_("All molecule tests passed.")) - finally: - stop_event.set() - - sys.exit(0) - - -if __name__ == "__main__": # pragma: no cover - cli() diff --git a/src/devx/translations.json b/src/devx/translations.json index ac717cc..dc3f151 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -783,14 +783,6 @@ "ru": "Additional directory to scan (default: scripts, tests). Can be repeated.", "zh": "Additional directory to scan (default: scripts, tests). Can be repeated." }, - "All molecule tests passed.": { - "bg": "All molecule tests passed.", - "de": "All molecule tests passed.", - "en": "All molecule tests passed.", - "pl": "Wszystkie testy molecule zakończone pomyślnie.", - "ru": "All molecule tests passed.", - "zh": "All molecule tests passed." - }, "Allow empty tag (PR mode where SHA is concrete).": { "bg": "Позволи празен таг (PR режим, където SHA е конкретен).", "de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).", @@ -799,13 +791,13 @@ "ru": "Разрешить пустой тег (режим PR, где SHA конкретен).", "zh": "允许空标签(SHA 为具体值的 PR 模式)。" }, - "Another molecule runner failed. Stopping this runner early.": { - "bg": "Another molecule runner failed. Stopping this runner early.", - "de": "Another molecule runner failed. Stopping this runner early.", - "en": "Another molecule runner failed. Stopping this runner early.", - "pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.", - "ru": "Another molecule runner failed. Stopping this runner early.", - "zh": "Another molecule runner failed. Stopping this runner early." + "Another runner failed. Stopping this runner early.": { + "bg": "Друг runner се провали. Спиране на този runner по-рано.", + "de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.", + "en": "Another runner failed. Stopping this runner early.", + "pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.", + "ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.", + "zh": "另一个 runner 失败。提前停止此 runner。" }, "Assigned {count} files to runner {runner_index}": { "bg": "Assigned {count} files to runner {runner_index}", @@ -1495,14 +1487,6 @@ "ru": "FAILED: {count} undocumented dependency/ies", "zh": "FAILED: {count} undocumented dependency/ies" }, - "FAILED: {pair} exited with code {code}": { - "bg": "FAILED: {pair} exited with code {code}", - "de": "FAILED: {pair} exited with code {code}", - "en": "FAILED: {pair} exited with code {code}", - "pl": "NIEUDANE: {pair} zakończone kodem {code}", - "ru": "FAILED: {pair} exited with code {code}", - "zh": "FAILED: {pair} exited with code {code}" - }, "Failed images: {names}": { "bg": "Failed images: {names}", "de": "Failed images: {names}", @@ -2263,14 +2247,6 @@ "ru": "PASS: All documentation checks passed!", "zh": "PASS: All documentation checks passed!" }, - "PASSED: {pair}": { - "bg": "PASSED: {pair}", - "de": "PASSED: {pair}", - "en": "PASSED: {pair}", - "pl": "UDANE: {pair}", - "ru": "PASSED: {pair}", - "zh": "PASSED: {pair}" - }, "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": { "bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", "de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", @@ -2743,14 +2719,6 @@ "ru": "Running: {cmd}", "zh": "Running: {cmd}" }, - "Running: {scenario} on {platform}": { - "bg": "Running: {scenario} on {platform}", - "de": "Running: {scenario} on {platform}", - "en": "Running: {scenario} on {platform}", - "pl": "Uruchamianie: {scenario} na {platform}", - "ru": "Running: {scenario} on {platform}", - "zh": "Running: {scenario} on {platform}" - }, "SSH key set up successfully": { "bg": "SSH ключът е настроен успешно", "de": "SSH-Schlüssel erfolgreich eingerichtet", @@ -3830,13 +3798,5 @@ "pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)", "zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)" - }, - "Cleaning up: running molecule destroy for {scenario}": { - "en": "Cleaning up: running molecule destroy for {scenario}", - "bg": "Изчистване: изпълнение на molecule destroy за {scenario}", - "de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}", - "pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}", - "ru": "Очистка: запуск molecule destroy для {scenario}", - "zh": "清理:正在为 {scenario} 运行 molecule destroy" } } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ab5de0a..4f05ea0 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -40,7 +40,6 @@ class TestCliGroups: result = runner.invoke(cli, ["molecule", "--help"]) assert result.exit_code == 0 assert "distribute" in result.output - assert "guard" in result.output assert "all" in result.output @@ -231,13 +230,6 @@ class TestMoleculeCommands: assert result.exit_code == 0 mock_run.assert_called_once_with("devx.molecule.discover_runners", []) - @patch("devx.cli._run_module") - def test_molecule_guard(self, mock_run: MagicMock) -> None: - runner = CliRunner() - result = runner.invoke(cli, ["molecule", "guard"]) - assert result.exit_code == 0 - mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", []) - @patch("devx.cli._run_module") def test_molecule_all(self, mock_run: MagicMock) -> None: runner = CliRunner() diff --git a/tests/unit/test_integration_guard.py b/tests/unit/test_integration_guard.py index 07607bf..b77a2a3 100644 --- a/tests/unit/test_integration_guard.py +++ b/tests/unit/test_integration_guard.py @@ -7,6 +7,7 @@ import subprocess # nosec B404 import time from unittest.mock import MagicMock, patch +import pytest from click.testing import CliRunner from devx.ci.integration_guard import cli @@ -111,9 +112,8 @@ class TestCli: 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("devx.ci.integration_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)), @@ -158,9 +158,8 @@ class TestCli: 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("devx.ci.integration_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)), @@ -203,9 +202,8 @@ class TestCli: 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("devx.ci.integration_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)), @@ -289,3 +287,125 @@ def test_main_module_block() -> None: namespace = dict(ig.__dict__) exec(compile(source, ig.__file__, "exec"), namespace) assert callable(namespace["cli"]) + + +class TestGetRunningJobs: + def test_returns_jobs(self) -> None: + with patch("devx.ci.integration_guard.requests.get") as mock_get: + mock_response = MagicMock() + mock_response.json.return_value = { + "jobs": [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + from devx.ci.integration_guard import get_running_jobs + + jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) + assert len(jobs) == 2 + mock_get.assert_called_once() + + def test_raises_on_request_error(self) -> None: + import requests + + with patch("devx.ci.integration_guard.requests.get") as mock_get: + mock_get.side_effect = requests.RequestException("boom") + with pytest.raises(requests.RequestException): + from devx.ci.integration_guard import get_running_jobs + + get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) + + +class TestAnyOtherRunnerFailed: + def test_detects_other_failure(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + {"name": "integration-tests (2)", "conclusion": "running"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is True + + def test_ignores_current_runner(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "integration-tests (0)", "conclusion": "failure"}, + {"name": "integration-tests (1)", "conclusion": "success"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is False + + def test_ignores_non_matching_jobs(self) -> None: + from devx.ci.integration_guard import any_other_runner_failed + + jobs = [ + {"name": "quality", "conclusion": "failure"}, + {"name": "integration-tests (1)", "conclusion": "success"}, + ] + assert any_other_runner_failed(jobs, "integration-tests", 0) is False + + +class TestPollForOtherFailures: + def test_sets_failed_event_when_other_runner_fails(self) -> None: + from devx.ci.integration_guard import poll_for_other_failures + + stop_event = MagicMock() + failed_event = MagicMock() + + def side_effect(*args, **kwargs): + if stop_event.wait.call_count < 1: + return [ + {"name": "integration-tests (0)", "conclusion": "success"}, + {"name": "integration-tests (1)", "conclusion": "failure"}, + ] + return [] + + with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs: + mock_get_jobs.side_effect = side_effect + stop_event.is_set.side_effect = [False, False] + stop_event.wait.return_value = True + + poll_for_other_failures( + "https://gitea.example", + "owner", + "repo", + "token", + 123, + "integration-tests", + 0, + stop_event, + failed_event, + ) + + failed_event.set.assert_called_once() + + def test_poll_warns_on_api_error(self) -> None: + import requests + + from devx.ci.integration_guard import poll_for_other_failures + + stop_event = MagicMock() + failed_event = MagicMock() + + with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs: + mock_get_jobs.side_effect = requests.RequestException("boom") + stop_event.is_set.side_effect = [False, True] + stop_event.wait.return_value = True + + poll_for_other_failures( + "https://gitea.example", + "owner", + "repo", + "token", + 123, + "integration-tests", + 0, + stop_event, + failed_event, + ) + + failed_event.set.assert_not_called() diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py deleted file mode 100644 index fda5ebc..0000000 --- a/tests/unit/test_molecule_ci_guard.py +++ /dev/null @@ -1,591 +0,0 @@ -"""Unit tests for scripts/ci/molecule_ci_guard.py.""" - -from __future__ import annotations - -import os -import subprocess # nosec B404 -import time -from pathlib import Path -from unittest.mock import MagicMock, patch - -import click -import pytest -import requests - -from devx.molecule.molecule_ci_guard import ( - any_other_runner_failed, - build_env_for_pair, - build_molecule_cmd, - cli, - get_running_jobs, - parse_pair, - poll_for_other_failures, - resolve_role_dir, -) - - -class TestGetRunningJobs: - def test_returns_jobs(self) -> None: - with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get: - mock_response = MagicMock() - mock_response.json.return_value = { - "jobs": [ - {"name": "molecule-tests (0)", "conclusion": "success"}, - {"name": "molecule-tests (1)", "conclusion": "failure"}, - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) - assert len(jobs) == 2 - mock_get.assert_called_once() - - def test_raises_on_request_error(self) -> None: - with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get: - mock_get.side_effect = requests.RequestException("boom") - with pytest.raises(requests.RequestException): - get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) - - -class TestAnyOtherRunnerFailed: - def test_detects_other_failure(self) -> None: - jobs = [ - {"name": "molecule-tests (0)", "conclusion": "success"}, - {"name": "molecule-tests (1)", "conclusion": "failure"}, - {"name": "molecule-tests (2)", "conclusion": "running"}, - ] - assert any_other_runner_failed(jobs, "molecule-tests", 0) is True - - def test_ignores_current_runner(self) -> None: - jobs = [ - {"name": "molecule-tests (0)", "conclusion": "failure"}, - {"name": "molecule-tests (1)", "conclusion": "success"}, - ] - assert any_other_runner_failed(jobs, "molecule-tests", 0) is False - - def test_ignores_non_molecule_jobs(self) -> None: - jobs = [ - {"name": "quality", "conclusion": "failure"}, - {"name": "molecule-tests (1)", "conclusion": "success"}, - ] - assert any_other_runner_failed(jobs, "molecule-tests", 0) is False - - -class TestBuildMoleculeCmd: - def test_default_scenario(self) -> None: - assert build_molecule_cmd("default") == ["molecule", "test"] - - def test_named_scenario(self) -> None: - assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"] - - -class TestBuildEnvForPair: - def test_with_command(self) -> None: - env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {}) - assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204" - assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest" - assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd" - assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true" - - def test_without_command(self) -> None: - env = build_env_for_pair("default|ubuntu-2204|img:latest|", {}) - assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204" - assert "MOLECULE_PLATFORM_COMMAND" not in env - - def test_without_command_removes_existing(self) -> None: - env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"}) - assert "MOLECULE_PLATFORM_COMMAND" not in env - - def test_preserves_existing_molecule_home(self) -> None: - """When MOLECULE_HOME is already set, it is not overridden.""" - env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"}) - assert env["MOLECULE_HOME"] == "/custom/home" - - def test_appends_matrix_index_to_platform_name(self) -> None: - """When MATRIX_INDEX is set, platform name gets a unique suffix.""" - env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {"MATRIX_INDEX": "3"}) - assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604-r3" - - def test_no_matrix_index_keeps_platform_name(self) -> None: - """Without MATRIX_INDEX, platform name is unchanged.""" - env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {}) - assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604" - - -class TestPollForOtherFailures: - def test_sets_failed_event_when_other_runner_fails(self) -> None: - stop_event = MagicMock() - failed_event = MagicMock() - - def side_effect(*args, **kwargs): - if stop_event.wait.call_count < 1: - return [ - {"name": "molecule-tests (0)", "conclusion": "success"}, - {"name": "molecule-tests (1)", "conclusion": "failure"}, - ] - return [] - - with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs: - mock_get_jobs.side_effect = side_effect - stop_event.is_set.side_effect = [False, False] - stop_event.wait.return_value = True - - poll_for_other_failures( - "https://gitea.example", - "owner", - "repo", - "token", - 123, - "molecule-tests", - 0, - stop_event, - failed_event, - ) - - failed_event.set.assert_called_once() - - def test_poll_warns_on_api_error(self) -> None: - stop_event = MagicMock() - failed_event = MagicMock() - - with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs: - mock_get_jobs.side_effect = requests.RequestException("boom") - stop_event.is_set.side_effect = [False, True] - stop_event.wait.return_value = True - - poll_for_other_failures( - "https://gitea.example", - "owner", - "repo", - "token", - 123, - "molecule-tests", - 0, - stop_event, - failed_event, - ) - - failed_event.set.assert_not_called() - - -class TestCli: - def test_all_pass(self) -> None: - from click.testing import CliRunner - - with ( - patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, - patch("time.sleep"), - ): - proc = MagicMock() - proc.poll.return_value = 0 - proc.returncode = 0 - mock_popen.return_value = proc - mock_run.return_value = MagicMock(returncode=0) - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 0 - assert "All molecule tests passed" in result.output - # Verify Docker prune was called between scenarios - mock_run.assert_called_once_with( - ["docker", "system", "prune", "-af", "--volumes"], - check=False, - capture_output=True, - timeout=60, - ) - - @patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token")) - def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None: - """When no token is available, cross-runner polling is skipped.""" - from click.testing import CliRunner - - with ( - patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, - patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll, - patch("time.sleep"), - ): - proc = MagicMock() - proc.poll.return_value = 0 - proc.returncode = 0 - mock_popen.return_value = proc - mock_run.return_value = MagicMock(returncode=0) - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 0 - mock_poll.assert_not_called() - - def test_invalid_pair_format_raises(self) -> None: - """Pair with fewer than 2 parts should raise.""" - from click.testing import CliRunner - - with ( - patch("devx.molecule.molecule_ci_guard.subprocess.Popen"), - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - ): - runner = CliRunner() - result = runner.invoke(cli, ["invalid_no_pipe"]) - assert result.exit_code != 0 - assert "Invalid pair format" in result.output - - def test_failure_exits_nonzero(self) -> None: - from click.testing import CliRunner - - with ( - patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("time.sleep"), - ): - proc = MagicMock() - proc.poll.return_value = 1 - proc.returncode = 1 - mock_popen.return_value = proc - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - assert "FAILED" in result.output - - def test_exits_before_starting_when_already_failed(self) -> None: - from click.testing import CliRunner - - with ( - patch.dict( - os.environ, - { - "GITEA_URL": "https://gitea.example", - "CI_GITEA_TOKEN": "token", - "RUN_ID": "123", - "JOB_NAME": "molecule-tests", - "MATRIX_INDEX": "0", - "GITEA_REPOSITORY": "my-org/my-repo", - "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.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, - patch("time.sleep"), - ): - mock_get_jobs.return_value = [ - {"name": "molecule-tests (0)", "conclusion": "running"}, - {"name": "molecule-tests (1)", "conclusion": "failure"}, - ] - proc = MagicMock() - proc.poll.return_value = 0 - proc.returncode = 0 - mock_popen.return_value = proc - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - assert "Another molecule runner failed" in result.output - - def test_keyboard_interrupt_kills_process(self) -> None: - from click.testing import CliRunner - - with ( - patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run"), - 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, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - mock_killpg.assert_called() - - def test_exits_when_other_runner_fails(self) -> None: - from click.testing import CliRunner - - 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": "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", - "CI_GITEA_TOKEN": "token", - "RUN_ID": "123", - "JOB_NAME": "molecule-tests", - "MATRIX_INDEX": "0", - "GITEA_REPOSITORY": "my-org/my-repo", - "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.subprocess.run"), - 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)), - ): - 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, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - mock_killpg.assert_called() - - def test_default_owner_repo_fallback(self) -> None: - from click.testing import CliRunner - - real_sleep = time.sleep - - with ( - patch.dict( - os.environ, - { - "GITEA_URL": "https://gitea.example", - "CI_GITEA_TOKEN": "token", - "RUN_ID": "123", - "JOB_NAME": "molecule-tests", - "MATRIX_INDEX": "0", - "GITEA_REPOSITORY": "invalid", - "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.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, - patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, - patch("time.sleep", side_effect=lambda x: real_sleep(0)), - ): - mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}] - proc = MagicMock() - proc.poll.return_value = 0 - proc.returncode = 0 - mock_popen.return_value = proc - mock_run.return_value = MagicMock(returncode=0) - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 0 - - def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None: - from click.testing import CliRunner - - 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": "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", - "CI_GITEA_TOKEN": "token", - "RUN_ID": "123", - "JOB_NAME": "molecule-tests", - "MATRIX_INDEX": "0", - "GITEA_REPOSITORY": "my-org/my-repo", - "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.subprocess.run"), - 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)), - ): - mock_getpgid.return_value = 123 - mock_killpg.side_effect = ProcessLookupError("no such process") - proc = MagicMock() - proc.poll.return_value = None - proc.wait.return_value = 0 - mock_popen.return_value = proc - - runner = CliRunner() - result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - - def test_exits_when_other_runner_fails_with_timeout(self) -> None: - from click.testing import CliRunner - - 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": "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", - "CI_GITEA_TOKEN": "token", - "RUN_ID": "123", - "JOB_NAME": "molecule-tests", - "MATRIX_INDEX": "0", - "GITEA_REPOSITORY": "my-org/my-repo", - "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.subprocess.run"), - 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)), - ): - mock_getpgid.return_value = 123 - mock_killpg.side_effect = [None, ProcessLookupError("no such process")] - 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, ["default|ubuntu-2204|img:latest|"]) - assert result.exit_code == 1 - - -def test_main_module_block() -> None: - import devx.molecule.molecule_ci_guard as mg - - with open(mg.__file__) as f: - source = f.read() - source = source.replace('if __name__ == "__main__":\n cli()\n', "") - namespace = dict(mg.__dict__) - exec(compile(source, mg.__file__, "exec"), namespace) - 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_auto_discovers(self, tmp_path: Path) -> None: - """Single-role mode auto-discovers first role with molecule/ dir.""" - roles_dir = tmp_path / "ansible" / "roles" - (roles_dir / "my_role" / "molecule").mkdir(parents=True) - result = resolve_role_dir("", None, tmp_path) - assert result == roles_dir / "my_role" - - def test_single_role_no_roles_returns_fallback(self, tmp_path: Path) -> None: - """When no roles exist, returns a fallback path (will error at runtime).""" - result = resolve_role_dir("", None, tmp_path) - assert "roles" in str(result) - - -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("devx.molecule.molecule_ci_guard.subprocess.run"), - patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, - patch("time.sleep"), - ): - proc = MagicMock() - proc.poll.return_value = 0 - proc.returncode = 0 - mock_popen.return_value = proc - mock_run.return_value = MagicMock(returncode=0) - - runner = CliRunner() - result = runner.invoke( - cli, - ["--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