Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8e6c69e9 | ||
|
|
ccb7023965 | ||
|
|
7dd15f1461 | ||
|
|
d4e4621fa1 | ||
|
|
a6f814c446 | ||
|
|
04aa5acb1f | ||
|
|
6973f9d851 | ||
|
|
2669a0ea73 | ||
|
|
03f057b55a | ||
|
|
706d6dafe0 | ||
|
|
03ddce427c | ||
|
|
9642d6884c | ||
|
|
b2074d6635 | ||
|
|
a6dddf25e7 | ||
|
|
01130a7385 | ||
|
|
07580c9280 | ||
|
|
6601d90bee | ||
|
|
0df79fed53 | ||
|
|
cf8287e683 | ||
|
|
9f1bdc4cf1 |
@@ -141,6 +141,7 @@ src/devx/
|
|||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
├── 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
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
|
|||||||
+55
-53
@@ -2,71 +2,73 @@
|
|||||||
|
|
||||||
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.49.0] - 2026-08-09
|
## [0.49.5] - 2026-08-07
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Skip dep resolution in setup-image with --no-deps
|
||||||
|
|
||||||
|
## [0.49.4] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add container.credentials for private registry auth
|
||||||
|
|
||||||
|
## [0.49.3] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Retry ansible-galaxy collection install on transient timeouts
|
||||||
|
|
||||||
|
## [0.49.2] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add fallback URL for tea download
|
||||||
|
|
||||||
|
## [0.49.1] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add container images to build-images workflow
|
||||||
|
|
||||||
|
## [0.49.0] - 2026-08-07
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Sync missing features from v0.49.x line to master
|
- Add --include-roles and --exclude-roles to distribute_molecule
|
||||||
|
## [0.48.0] - 2026-07-22
|
||||||
## [0.48.2] - 2026-08-09
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Remove dead translation keys and add missing one
|
|
||||||
|
|
||||||
## [0.48.1] - 2026-08-08
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- *(setup)* Extract version from filename for mirror installs
|
|
||||||
|
|
||||||
## [0.48.0] - 2026-08-08
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- *(setup)* Mirror Ansible collections from Gitea registry with auth
|
- Extract reusable components from infra and grm into devx
|
||||||
|
|
||||||
## [0.47.10] - 2026-08-05
|
## [0.48.0] - 2026-07-22
|
||||||
|
|
||||||
### Bug Fixes
|
### Features
|
||||||
|
|
||||||
- Unique molecule container names per CI runner
|
- Extract reusable components from infra and grm into devx
|
||||||
|
|
||||||
## [0.47.9] - 2026-08-03
|
## [Unreleased]
|
||||||
|
|
||||||
### Bug Fixes
|
### Features
|
||||||
|
|
||||||
- Unique molecule container names per CI runner
|
- Extract reusable components from infra and grm into devx:
|
||||||
|
- `devx.utils.ui.say()` — unified click.echo + logging output
|
||||||
## [0.47.8] - 2026-08-03
|
- `devx.utils.api.APIClient` — base HTTP API client class with retry logic
|
||||||
|
- `devx.utils.jinja` — Jinja2 environment helpers with Ansible-compatible filters
|
||||||
### Bug Fixes
|
- `devx.i18n.configure_i18n()` — configurable `lang_env_var` and `translations_path_env_var`
|
||||||
|
- `devx.ci.cancel_superseded_runs` — cancel in-flight CI runs for the same PR branch
|
||||||
- Increase CI_SCALE_FACTOR default from 4 to 6
|
- `devx.ci.check_workflow_artifact_deps` — verify artifact download jobs depend on upload jobs
|
||||||
|
- `devx.ci.check_workflow_tofu_init` — verify tofu-state jobs have a tofu-init step
|
||||||
## [0.47.7] - 2026-08-03
|
- `devx.tools.check_docker_init` — check Docker Compose services with healthchecks have init: true
|
||||||
|
- `devx.tools.check_ansible_set_fact_to_json` — check set_fact tasks don't misuse to_json
|
||||||
### Bug Fixes
|
- `devx.tools.check_alert_rules` — validate Prometheus alert rules with promtool
|
||||||
|
- Add `jinja2` and `pyyaml` as core dependencies (previously in `deploy` extras only)
|
||||||
- Scale check_test_speed limits on CI runners
|
- Register new CLI commands: `devx ci cancel-superseded-runs`, `devx ci check-workflow-artifact-deps`,
|
||||||
|
`devx ci check-workflow-tofu-init`, `devx tools check-docker-init`,
|
||||||
## [0.47.6] - 2026-08-03
|
`devx tools check-ansible-set-fact-to-json`, `devx tools check-alert-rules`
|
||||||
|
- Add Makefile targets for all new check tools
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Configure git auth in setup_image for git+https deps
|
|
||||||
|
|
||||||
## [0.47.5] - 2026-08-03
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Push wiki to main branch instead of master
|
|
||||||
|
|
||||||
## [0.47.4] - 2026-08-03
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Add User-Agent header to _download in install_tools
|
|
||||||
|
|
||||||
## [0.47.3] - 2026-07-17
|
## [0.47.3] - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.0",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.49.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.49.5"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.49.0,<0.50"`).
|
> (for example, `"devx>=0.49.5,<0.50"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
@@ -226,6 +226,10 @@ 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 # list all scenarios
|
||||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
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)
|
# Run all molecule scenarios locally (sequential)
|
||||||
python -m devx.molecule.molecule_all
|
python -m devx.molecule.molecule_all
|
||||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||||
@@ -299,6 +303,7 @@ devx --version
|
|||||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||||
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
||||||
| `devx molecule distribute` | Distribute molecule test pairs across parallel 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)
|
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
||||||
in the wiki for full command documentation with examples.
|
in the wiki for full command documentation with examples.
|
||||||
|
|||||||
+5
-5
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.0",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.49.0"` or `"devx>=0.49.0,<0.50"`.
|
Pin a specific version if needed: `"devx==0.49.5"` or `"devx>=0.49.5,<0.50"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ devx is a self-contained Python package under `src/devx/`:
|
|||||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||||
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
|
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
||||||
platforms
|
start_docker, platforms
|
||||||
|
|
||||||
See [Architecture](Architecture) for the full package structure, module
|
See [Architecture](Architecture) for the full package structure, module
|
||||||
descriptions, design principles, and data flow diagrams.
|
descriptions, design principles, and data flow diagrams.
|
||||||
@@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups:
|
|||||||
|
|
||||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||||
- `devx tools <command>` — Developer tools (9 commands)
|
- `devx tools <command>` — Developer tools (9 commands)
|
||||||
- `devx molecule <command>` — Molecule testing (3 commands, optional)
|
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||||
|
|
||||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ src/devx/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute scenarios across runners
|
├── 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
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker is available for molecule
|
├── start_docker.py # Ensure Docker is available for molecule
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
@@ -299,9 +300,9 @@ Distributes files matching a glob pattern across N parallel runners
|
|||||||
|
|
||||||
### `integration_guard.py`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with cross-runner failure detection. A background thread polls
|
Runs pytest with the same cross-runner failure detection mechanism used by
|
||||||
the Gitea API. If any other integration-tests matrix runner reports failure,
|
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
||||||
the current pytest subprocess is killed and this runner exits early.
|
failure, the current pytest subprocess is killed and this runner exits early.
|
||||||
|
|
||||||
## Developer tools (`devx.tools`)
|
## Developer tools (`devx.tools`)
|
||||||
|
|
||||||
@@ -381,6 +382,13 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role
|
|||||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||||
platforms.
|
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`
|
### `molecule_all.py`
|
||||||
|
|
||||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||||
|
|||||||
@@ -478,6 +478,15 @@ python -m devx.molecule.distribute_molecule --list
|
|||||||
python -m devx.molecule.distribute_molecule --list-platforms
|
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 <dir>] pair1 pair2 ...
|
||||||
|
```
|
||||||
|
|
||||||
### `validate_commit_msg.py`
|
### `validate_commit_msg.py`
|
||||||
|
|
||||||
Validates commit messages. On feature branches: conventional commits only
|
Validates commit messages. On feature branches: conventional commits only
|
||||||
|
|||||||
@@ -631,3 +631,29 @@ Options:
|
|||||||
- `--list-platforms` — list all platforms, one per line
|
- `--list-platforms` — list all platforms, one per line
|
||||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||||
`ansible/roles`)
|
`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 <dir>` — 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
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.0",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.49.0",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.49.0"
|
__version__ = "0.49.5"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ REQUIRED_SCRIPTS = [
|
|||||||
"detect_release_commit.py",
|
"detect_release_commit.py",
|
||||||
"push_badges.py",
|
"push_badges.py",
|
||||||
"distribute_molecule.py",
|
"distribute_molecule.py",
|
||||||
|
"molecule_ci_guard.py",
|
||||||
"validate_commit_msg.py",
|
"validate_commit_msg.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run integration tests with cross-runner failure detection.
|
"""Run integration tests with cross-runner failure detection.
|
||||||
|
|
||||||
Wraps ``pytest`` with Gitea API polling. If any other integration-tests
|
Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
||||||
matrix runner reports failure, the current pytest subprocess is killed
|
``molecule_ci_guard``. If any other integration-tests matrix runner
|
||||||
and this runner exits early with code 1.
|
reports failure, the current pytest subprocess is killed and this runner
|
||||||
|
exits early with code 1.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -34,62 +35,17 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
|
||||||
|
|
||||||
from devx.config import REPO_NAME, REPO_OWNER
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
from devx.molecule.molecule_ci_guard import (
|
||||||
|
poll_for_other_failures,
|
||||||
|
)
|
||||||
from devx.tokens import get_ci_token
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
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.command(context_settings={"ignore_unknown_options": True})
|
||||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
|||||||
|
|
||||||
# Push
|
# Push
|
||||||
result = subprocess.run( # nosec
|
result = subprocess.run( # nosec
|
||||||
["git", "push", "--force", wiki_url, "HEAD:main"],
|
["git", "push", "--force", wiki_url, "HEAD:master"],
|
||||||
cwd=wiki_dir,
|
cwd=wiki_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.molecule.discover_runners", list(args))
|
_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")
|
@molecule.command("all")
|
||||||
@click.argument("args", nargs=-1)
|
@click.argument("args", nargs=-1)
|
||||||
def molecule_all(args: tuple[str, ...]) -> None:
|
def molecule_all(args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
#!/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()
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
|
||||||
|
rc = process.returncode
|
||||||
|
|
||||||
|
if rc != 0:
|
||||||
|
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||||
|
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()
|
||||||
@@ -11,18 +11,6 @@ Usage:
|
|||||||
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||||
that pytest emits per-test timing lines alongside the summary. Both the
|
that pytest emits per-test timing lines alongside the summary. Both the
|
||||||
total wall-clock time and individual test durations are parsed and validated.
|
total wall-clock time and individual test durations are parsed and validated.
|
||||||
|
|
||||||
CI runner scaling
|
|
||||||
-----------------
|
|
||||||
CI runners (Gitea Actions Docker containers) are typically 5-8x slower than
|
|
||||||
local development machines due to shared CPU, fewer cores, and container
|
|
||||||
overhead. When the ``CI`` environment variable is set (standard CI
|
|
||||||
convention), both the total and per-test limits are multiplied by
|
|
||||||
``CI_SCALE_FACTOR`` (default 6) to account for this. This keeps the local
|
|
||||||
budget strict while preventing false failures on slower CI runners.
|
|
||||||
|
|
||||||
The scale factor can be overridden via the ``DEVX_CI_SCALE_FACTOR``
|
|
||||||
environment variable.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -39,12 +27,6 @@ DEFAULT_MAX_SECONDS = 10.0
|
|||||||
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||||
TEST_COMMAND = ["make", "test-unit"]
|
TEST_COMMAND = ["make", "test-unit"]
|
||||||
|
|
||||||
# CI runners are typically 5-8x slower than local machines (shared CPU,
|
|
||||||
# fewer cores, container overhead). Scale limits up when running on CI
|
|
||||||
# so the gate catches real regressions, not infrastructure slowness.
|
|
||||||
CI_SCALE_FACTOR = float(os.environ.get("DEVX_CI_SCALE_FACTOR", "6"))
|
|
||||||
_IS_CI = bool(os.environ.get("CI") or os.environ.get("GITEA_ACTIONS"))
|
|
||||||
|
|
||||||
# Matches pytest summary line: "234 passed in 0.70s"
|
# 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")
|
||||||
|
|
||||||
@@ -56,13 +38,6 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
|||||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
||||||
|
|
||||||
|
|
||||||
def _ci_scale_limit(limit: float) -> float:
|
|
||||||
"""Scale a time limit by the CI factor when running on CI."""
|
|
||||||
if _IS_CI:
|
|
||||||
return limit * CI_SCALE_FACTOR
|
|
||||||
return limit
|
|
||||||
|
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
@@ -148,38 +123,21 @@ def check_per_test_speed(
|
|||||||
|
|
||||||
def main(max_seconds: float, max_single_seconds: float) -> None:
|
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||||
"""Run tests, parse timings, and enforce both budgets."""
|
"""Run tests, parse timings, and enforce both budgets."""
|
||||||
# Scale limits for CI runners (slower CPU, fewer workers).
|
|
||||||
effective_max = _ci_scale_limit(max_seconds)
|
|
||||||
effective_single = _ci_scale_limit(max_single_seconds)
|
|
||||||
|
|
||||||
if _IS_CI:
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x "
|
|
||||||
"(total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
|
||||||
factor=CI_SCALE_FACTOR,
|
|
||||||
orig=max_seconds,
|
|
||||||
eff=effective_max,
|
|
||||||
orig_s=max_single_seconds,
|
|
||||||
eff_s=effective_single,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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, effective_max)
|
check_speed(duration, max_seconds)
|
||||||
|
|
||||||
if effective_single > 0:
|
if max_single_seconds > 0:
|
||||||
per_test = parse_per_test_durations(combined)
|
per_test = parse_per_test_durations(combined)
|
||||||
violations = check_per_test_speed(per_test, effective_single)
|
violations = check_per_test_speed(per_test, max_single_seconds)
|
||||||
if violations:
|
if violations:
|
||||||
msg = _(
|
msg = _(
|
||||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
count=len(violations),
|
count=len(violations),
|
||||||
limit=effective_single,
|
limit=max_single_seconds,
|
||||||
)
|
)
|
||||||
click.echo(f"\n{msg}", err=True)
|
click.echo(f"\n{msg}", err=True)
|
||||||
for v in violations:
|
for v in violations:
|
||||||
@@ -190,8 +148,8 @@ def main(max_seconds: float, max_single_seconds: float) -> None:
|
|||||||
_(
|
_(
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test 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=effective_max,
|
max=max_seconds,
|
||||||
single=effective_single,
|
single=max_single_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -65,13 +65,8 @@ def _ensure_target_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _download(url: str, dest: Path) -> None:
|
def _download(url: str, dest: Path) -> None:
|
||||||
"""Download a file from ``url`` to ``dest`` with a 60s timeout.
|
"""Download a file from ``url`` to ``dest`` with a 60s timeout."""
|
||||||
|
with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
|
||||||
A User-Agent header is set because some CDNs (e.g. dl.gitea.com)
|
|
||||||
return 403 to requests with Python's default User-Agent.
|
|
||||||
"""
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "devx/install-tools"})
|
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
|
|
||||||
shutil.copyfileobj(resp, f)
|
shutil.copyfileobj(resp, f)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,17 +83,6 @@ def _install_in_image(
|
|||||||
username,
|
username,
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
# Configure git URL rewrite so git+https dependencies can authenticate
|
|
||||||
subprocess.run( # nosec B603, B607
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"config",
|
|
||||||
"--global",
|
|
||||||
f"url.https://{username}:{token}@{gitea_host}/.insteadOf",
|
|
||||||
f"https://{gitea_host}/",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
||||||
subprocess.run(cmd, check=True, env=env) # nosec B603
|
subprocess.run(cmd, check=True, env=env) # nosec B603
|
||||||
|
|||||||
+39
-15
@@ -783,6 +783,14 @@
|
|||||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
"zh": "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).": {
|
"Allow empty tag (PR mode where SHA is concrete).": {
|
||||||
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||||
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||||
@@ -791,13 +799,13 @@
|
|||||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||||
},
|
},
|
||||||
"Another runner failed. Stopping this runner early.": {
|
"Another molecule runner failed. Stopping this runner early.": {
|
||||||
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
|
"bg": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
|
"de": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"en": "Another runner failed. Stopping this runner early.",
|
"en": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||||
"ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.",
|
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||||
"zh": "另一个 runner 失败。提前停止此 runner。"
|
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||||
},
|
},
|
||||||
"Assigned {count} files to runner {runner_index}": {
|
"Assigned {count} files to runner {runner_index}": {
|
||||||
"bg": "Assigned {count} files to runner {runner_index}",
|
"bg": "Assigned {count} files to runner {runner_index}",
|
||||||
@@ -1487,6 +1495,14 @@
|
|||||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||||
"zh": "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}": {
|
"Failed images: {names}": {
|
||||||
"bg": "Failed images: {names}",
|
"bg": "Failed images: {names}",
|
||||||
"de": "Failed images: {names}",
|
"de": "Failed images: {names}",
|
||||||
@@ -2247,6 +2263,14 @@
|
|||||||
"ru": "PASS: All documentation checks passed!",
|
"ru": "PASS: All documentation checks passed!",
|
||||||
"zh": "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.": {
|
"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.",
|
"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.",
|
"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.",
|
||||||
@@ -2719,6 +2743,14 @@
|
|||||||
"ru": "Running: {cmd}",
|
"ru": "Running: {cmd}",
|
||||||
"zh": "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": {
|
"SSH key set up successfully": {
|
||||||
"bg": "SSH ключът е настроен успешно",
|
"bg": "SSH ключът е настроен успешно",
|
||||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||||
@@ -3790,13 +3822,5 @@
|
|||||||
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||||
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||||
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
|
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
|
||||||
},
|
|
||||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)": {
|
|
||||||
"en": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
|
||||||
"bg": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
|
||||||
"de": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
|
||||||
"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)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from devx.tools.check_test_speed import (
|
|||||||
DEFAULT_MAX_SECONDS,
|
DEFAULT_MAX_SECONDS,
|
||||||
DEFAULT_MAX_SINGLE_SECONDS,
|
DEFAULT_MAX_SINGLE_SECONDS,
|
||||||
TEST_COMMAND,
|
TEST_COMMAND,
|
||||||
_ci_scale_limit,
|
|
||||||
check_per_test_speed,
|
check_per_test_speed,
|
||||||
check_speed,
|
check_speed,
|
||||||
cli,
|
cli,
|
||||||
@@ -145,26 +144,7 @@ def test_main_module_block() -> None:
|
|||||||
mock_cli.assert_called_once_with([])
|
mock_cli.assert_called_once_with([])
|
||||||
|
|
||||||
|
|
||||||
class TestCiScaleLimit:
|
|
||||||
def test_no_scaling_when_not_ci(self) -> None:
|
|
||||||
with patch("devx.tools.check_test_speed._IS_CI", False):
|
|
||||||
assert _ci_scale_limit(10.0) == 10.0
|
|
||||||
assert _ci_scale_limit(0.5) == 0.5
|
|
||||||
|
|
||||||
def test_scales_when_ci(self) -> None:
|
|
||||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
|
||||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0):
|
|
||||||
assert _ci_scale_limit(10.0) == 40.0
|
|
||||||
assert _ci_scale_limit(0.5) == 2.0
|
|
||||||
|
|
||||||
def test_custom_scale_factor(self) -> None:
|
|
||||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
|
||||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 2.5):
|
|
||||||
assert _ci_scale_limit(10.0) == 25.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
|
||||||
@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")
|
||||||
@@ -194,7 +174,6 @@ class TestMain:
|
|||||||
mock_parse_per.assert_called_once()
|
mock_parse_per.assert_called_once()
|
||||||
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
|
||||||
@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_total_exits(
|
def test_slow_total_exits(
|
||||||
@@ -210,7 +189,6 @@ class TestMain:
|
|||||||
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._IS_CI", False)
|
|
||||||
@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")
|
||||||
@@ -235,7 +213,6 @@ class TestMain:
|
|||||||
assert "Per-test speed check FAILED" in result.output
|
assert "Per-test speed check FAILED" in result.output
|
||||||
assert "test_slow" in result.output
|
assert "test_slow" in result.output
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
|
||||||
@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,
|
||||||
@@ -248,7 +225,6 @@ class TestMain:
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Could not parse" in result.output
|
assert "Could not parse" in result.output
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
|
||||||
@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")
|
||||||
@@ -272,7 +248,6 @@ class TestMain:
|
|||||||
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._IS_CI", False)
|
|
||||||
@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")
|
||||||
@@ -295,7 +270,6 @@ class TestMain:
|
|||||||
mock_parse_per.assert_not_called()
|
mock_parse_per.assert_not_called()
|
||||||
mock_check_per.assert_not_called()
|
mock_check_per.assert_not_called()
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
|
||||||
@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")
|
||||||
@@ -318,32 +292,3 @@ class TestMain:
|
|||||||
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_check_per.assert_called_once_with([], 1.0)
|
mock_check_per.assert_called_once_with([], 1.0)
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed._IS_CI", True)
|
|
||||||
@patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0)
|
|
||||||
@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_ci_scales_limits(
|
|
||||||
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 = 30.0 # would fail local (10s) but pass CI (40s)
|
|
||||||
mock_parse_per.return_value = []
|
|
||||||
mock_check_per.return_value = []
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "CI environment detected" in result.output
|
|
||||||
assert "scaling limits by 4.0x" in result.output
|
|
||||||
# check_speed called with scaled limit
|
|
||||||
mock_check.assert_called_once_with(30.0, 40.0)
|
|
||||||
mock_check_per.assert_called_once_with([], 2.0)
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class TestCliGroups:
|
|||||||
result = runner.invoke(cli, ["molecule", "--help"])
|
result = runner.invoke(cli, ["molecule", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "distribute" in result.output
|
assert "distribute" in result.output
|
||||||
|
assert "guard" in result.output
|
||||||
assert "all" in result.output
|
assert "all" in result.output
|
||||||
|
|
||||||
|
|
||||||
@@ -230,6 +231,13 @@ class TestMoleculeCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
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")
|
@patch("devx.cli._run_module")
|
||||||
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -67,10 +66,6 @@ class TestDownload:
|
|||||||
with patch("urllib.request.urlopen", return_value=_FakeResponse()) as mock_urlopen:
|
with patch("urllib.request.urlopen", return_value=_FakeResponse()) as mock_urlopen:
|
||||||
install_tools._download("https://example.com/file", dest)
|
install_tools._download("https://example.com/file", dest)
|
||||||
mock_urlopen.assert_called_once()
|
mock_urlopen.assert_called_once()
|
||||||
call_args = mock_urlopen.call_args
|
|
||||||
req = call_args.args[0]
|
|
||||||
assert isinstance(req, urllib.request.Request)
|
|
||||||
assert req.get_header("User-agent") == "devx/install-tools"
|
|
||||||
assert dest.read_bytes() == b"data"
|
assert dest.read_bytes() == b"data"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import subprocess # nosec B404
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.ci.integration_guard import cli
|
from devx.ci.integration_guard import cli
|
||||||
@@ -112,8 +111,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
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.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -158,8 +158,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
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.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
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.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -202,8 +203,9 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
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.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -287,125 +289,3 @@ def test_main_module_block() -> None:
|
|||||||
namespace = dict(ig.__dict__)
|
namespace = dict(ig.__dict__)
|
||||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||||
assert callable(namespace["cli"])
|
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()
|
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user