Public Access
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ee1830e5 | ||
|
|
5cacbdec09 | ||
|
|
bd4530094e | ||
|
|
4d0aa326a1 | ||
|
|
a13fbddce6 | ||
|
|
8fddcff237 | ||
|
|
dede38cbe8 | ||
|
|
8f3c483eff | ||
|
|
30389ff3e7 | ||
|
|
ef1ff15593 | ||
|
|
0d0580c4fd | ||
|
|
ed3bd75367 | ||
|
|
ed0a282a52 | ||
|
|
e4e0a534ff | ||
|
|
e35ee2d71a | ||
|
|
1d9e505432 | ||
|
|
ddb0f17886 | ||
|
|
a8a8b743f3 | ||
|
|
a487bddb09 | ||
|
|
e3a37c95c1 | ||
|
|
9bb461e12f | ||
|
|
32193a0e6d | ||
|
|
155c4a204a | ||
|
|
491137f944 | ||
|
|
48cd33be22 | ||
|
|
2fae9bc723 | ||
|
|
bfc2ebec81 | ||
|
|
e01c39b4b8 | ||
|
|
aa93e894a6 |
@@ -32,7 +32,11 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
is-release: ${{ steps.check.outputs.is-release }}
|
is-release: ${{ steps.check.outputs.is-release }}
|
||||||
@@ -116,7 +120,11 @@ jobs:
|
|||||||
needs: [build-and-push]
|
needs: [build-and-push]
|
||||||
if: always() && needs.build-and-push.result == 'success'
|
if: always() && needs.build-and-push.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
+10
-2
@@ -18,7 +18,11 @@ jobs:
|
|||||||
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
||||||
validate:
|
validate:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -140,7 +144,11 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
needs.validate.result == 'success'
|
needs.validate.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
detect-and-configure:
|
detect-and-configure:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -99,7 +103,11 @@ jobs:
|
|||||||
needs: [detect-and-configure]
|
needs: [detect-and-configure]
|
||||||
if: always() && needs.detect-and-configure.result == 'success'
|
if: always() && needs.detect-and-configure.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.release-tag.outputs.tag }}
|
tag: ${{ steps.release-tag.outputs.tag }}
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ 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
|
||||||
|
|||||||
+35
-31
@@ -2,55 +2,59 @@
|
|||||||
|
|
||||||
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.2] - 2026-08-07
|
## [0.48.1] - 2026-08-08
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Add fallback URL for tea download
|
- *(setup)* Extract version from filename for mirror installs
|
||||||
|
|
||||||
## [0.49.1] - 2026-08-07
|
## [0.48.0] - 2026-08-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- *(setup)* Mirror Ansible collections from Gitea registry with auth
|
||||||
|
|
||||||
|
## [0.47.10] - 2026-08-05
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
- Add container images to build-images workflow
|
- Unique molecule container names per CI runner
|
||||||
|
|
||||||
## [0.49.0] - 2026-08-07
|
## [0.47.9] - 2026-08-03
|
||||||
|
|
||||||
### Features
|
### Bug Fixes
|
||||||
|
|
||||||
- Add --include-roles and --exclude-roles to distribute_molecule
|
- Unique molecule container names per CI runner
|
||||||
## [0.48.0] - 2026-07-22
|
|
||||||
|
|
||||||
### Features
|
## [0.47.8] - 2026-08-03
|
||||||
|
|
||||||
- Extract reusable components from infra and grm into devx
|
### Bug Fixes
|
||||||
|
|
||||||
## [0.48.0] - 2026-07-22
|
- Increase CI_SCALE_FACTOR default from 4 to 6
|
||||||
|
|
||||||
### Features
|
## [0.47.7] - 2026-08-03
|
||||||
|
|
||||||
- Extract reusable components from infra and grm into devx
|
### Bug Fixes
|
||||||
|
|
||||||
## [Unreleased]
|
- Scale check_test_speed limits on CI runners
|
||||||
|
|
||||||
### Features
|
## [0.47.6] - 2026-08-03
|
||||||
|
|
||||||
- Extract reusable components from infra and grm into devx:
|
### Bug Fixes
|
||||||
- `devx.utils.ui.say()` — unified click.echo + logging output
|
|
||||||
- `devx.utils.api.APIClient` — base HTTP API client class with retry logic
|
- Configure git auth in setup_image for git+https deps
|
||||||
- `devx.utils.jinja` — Jinja2 environment helpers with Ansible-compatible filters
|
|
||||||
- `devx.i18n.configure_i18n()` — configurable `lang_env_var` and `translations_path_env_var`
|
## [0.47.5] - 2026-08-03
|
||||||
- `devx.ci.cancel_superseded_runs` — cancel in-flight CI runs for the same PR branch
|
|
||||||
- `devx.ci.check_workflow_artifact_deps` — verify artifact download jobs depend on upload jobs
|
### Bug Fixes
|
||||||
- `devx.ci.check_workflow_tofu_init` — verify tofu-state jobs have a tofu-init step
|
|
||||||
- `devx.tools.check_docker_init` — check Docker Compose services with healthchecks have init: true
|
- Push wiki to main branch instead of master
|
||||||
- `devx.tools.check_ansible_set_fact_to_json` — check set_fact tasks don't misuse to_json
|
|
||||||
- `devx.tools.check_alert_rules` — validate Prometheus alert rules with promtool
|
## [0.47.4] - 2026-08-03
|
||||||
- Add `jinja2` and `pyyaml` as core dependencies (previously in `deploy` extras only)
|
|
||||||
- Register new CLI commands: `devx ci cancel-superseded-runs`, `devx ci check-workflow-artifact-deps`,
|
### Bug Fixes
|
||||||
`devx ci check-workflow-tofu-init`, `devx tools check-docker-init`,
|
|
||||||
`devx tools check-ansible-set-fact-to-json`, `devx tools check-alert-rules`
|
- Add User-Agent header to _download in install_tools
|
||||||
- Add Makefile targets for all new check tools
|
|
||||||
|
|
||||||
## [0.47.3] - 2026-07-17
|
## [0.47.3] - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||||
setup-image:
|
setup-image:
|
||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir --no-deps -e . 2>/dev/null; \
|
||||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
install-hooks:
|
install-hooks:
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.2",
|
"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.2"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.49.5"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.49.2,<0.50"`).
|
> (for example, `"devx>=0.49.5,<0.50"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
@@ -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 # 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
|
||||||
@@ -303,7 +299,6 @@ 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.
|
||||||
|
|||||||
+11
-11
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.2",
|
"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.2"` or `"devx>=0.49.2,<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_ci_guard, molecule_all, discover_runners,
|
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
|
||||||
start_docker, platforms
|
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 (4 commands, optional)
|
- `devx molecule <command>` — Molecule testing (3 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,7 +50,6 @@ 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
|
||||||
@@ -300,9 +299,9 @@ Distributes files matching a glob pattern across N parallel runners
|
|||||||
|
|
||||||
### `integration_guard.py`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with the same cross-runner failure detection mechanism used by
|
Runs pytest with cross-runner failure detection. A background thread polls
|
||||||
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
the Gitea API. If any other integration-tests matrix runner reports failure,
|
||||||
failure, the current pytest subprocess is killed and this runner exits early.
|
the current pytest subprocess is killed and this runner exits early.
|
||||||
|
|
||||||
## Developer tools (`devx.tools`)
|
## 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
|
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,15 +478,6 @@ 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,29 +631,3 @@ 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.2",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.49.2",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -64,11 +64,16 @@ molecule = [
|
|||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
]
|
]
|
||||||
# Deploy tools (for infra staging/production deployments)
|
# Deploy tools (for infra staging/production deployments)
|
||||||
|
# Versions aligned with infra's pyproject.toml to avoid reinstalls on every CI job.
|
||||||
|
# bcrypt and PyJWT are infra deps not in devx core — included here so the CI
|
||||||
|
# image has them and setup-image can use --no-deps (skip dep resolution).
|
||||||
deploy = [
|
deploy = [
|
||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
"boto3==1.43.37",
|
"boto3==1.43.44",
|
||||||
"docker==7.1.0",
|
"docker==7.1.0",
|
||||||
"cryptography==49.0.0",
|
"cryptography==50.0.0",
|
||||||
|
"bcrypt==5.0.0",
|
||||||
|
"PyJWT==2.13.0",
|
||||||
]
|
]
|
||||||
# Full dev environment (local development)
|
# Full dev environment (local development)
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -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.2"
|
__version__ = "0.49.5"
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ 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,10 +1,9 @@
|
|||||||
#!/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 the same Gitea API polling mechanism used by
|
Wraps ``pytest`` with Gitea API polling. If any other integration-tests
|
||||||
``molecule_ci_guard``. If any other integration-tests matrix runner
|
matrix runner reports failure, the current pytest subprocess is killed
|
||||||
reports failure, the current pytest subprocess is killed and this runner
|
and this runner exits early with code 1.
|
||||||
exits early with code 1.
|
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -35,17 +34,62 @@ 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:master"],
|
["git", "push", "--force", wiki_url, "HEAD:main"],
|
||||||
cwd=wiki_dir,
|
cwd=wiki_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -301,13 +301,6 @@ 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:
|
||||||
|
|||||||
@@ -1,284 +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()
|
|
||||||
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,6 +11,18 @@ 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
|
||||||
@@ -27,6 +39,12 @@ 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")
|
||||||
|
|
||||||
@@ -38,6 +56,13 @@ _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).
|
||||||
|
|
||||||
@@ -123,21 +148,38 @@ 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, max_seconds)
|
check_speed(duration, effective_max)
|
||||||
|
|
||||||
if max_single_seconds > 0:
|
if effective_single > 0:
|
||||||
per_test = parse_per_test_durations(combined)
|
per_test = parse_per_test_durations(combined)
|
||||||
violations = check_per_test_speed(per_test, max_single_seconds)
|
violations = check_per_test_speed(per_test, effective_single)
|
||||||
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=max_single_seconds,
|
limit=effective_single,
|
||||||
)
|
)
|
||||||
click.echo(f"\n{msg}", err=True)
|
click.echo(f"\n{msg}", err=True)
|
||||||
for v in violations:
|
for v in violations:
|
||||||
@@ -148,8 +190,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=max_seconds,
|
max=effective_max,
|
||||||
single=max_single_seconds,
|
single=effective_single,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
from devx.tokens import get_developer_token
|
from devx.tokens import get_developer_token
|
||||||
|
|
||||||
@@ -56,13 +57,22 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _install_ansible_collections(bin_dir: str) -> None:
|
def _install_ansible_collections(bin_dir: str) -> None:
|
||||||
"""Install required Ansible Galaxy collections if requirements exist."""
|
"""Install required Ansible Galaxy collections if requirements exist.
|
||||||
|
|
||||||
|
Retries up to 3 times with exponential backoff to handle transient
|
||||||
|
network timeouts when contacting galaxy.ansible.com.
|
||||||
|
"""
|
||||||
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
||||||
requirements = Path("ansible/requirements.yml")
|
requirements = Path("ansible/requirements.yml")
|
||||||
if not requirements.exists():
|
if not requirements.exists():
|
||||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||||
return
|
return
|
||||||
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
|
||||||
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True)
|
||||||
|
def _do_install() -> None:
|
||||||
|
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||||
|
|
||||||
|
_do_install()
|
||||||
|
|
||||||
|
|
||||||
def _configure_tea_login() -> None:
|
def _configure_tea_login() -> None:
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ def _install_in_image(
|
|||||||
link.symlink_to(opt_venv)
|
link.symlink_to(opt_venv)
|
||||||
|
|
||||||
# Build pip install command
|
# Build pip install command
|
||||||
|
# --no-deps: the CI image already has all dependencies pre-installed.
|
||||||
|
# We only need to install the project itself in editable mode.
|
||||||
spec = f".[{extras}]" if extras else "."
|
spec = f".[{extras}]" if extras else "."
|
||||||
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
||||||
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
|
cmd = [pip_bin, "install", "--no-cache-dir", "--no-deps", "-e", spec]
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
try:
|
try:
|
||||||
@@ -81,6 +83,17 @@ 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
|
||||||
|
|||||||
+15
-39
@@ -783,14 +783,6 @@
|
|||||||
"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).",
|
||||||
@@ -799,13 +791,13 @@
|
|||||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||||
},
|
},
|
||||||
"Another molecule runner failed. Stopping this runner early.": {
|
"Another runner failed. Stopping this runner early.": {
|
||||||
"bg": "Another molecule runner failed. Stopping this runner early.",
|
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
|
||||||
"de": "Another molecule runner failed. Stopping this runner early.",
|
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
|
||||||
"en": "Another molecule runner failed. Stopping this runner early.",
|
"en": "Another runner failed. Stopping this runner early.",
|
||||||
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
"pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
"ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.",
|
||||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
"zh": "另一个 runner 失败。提前停止此 runner。"
|
||||||
},
|
},
|
||||||
"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}",
|
||||||
@@ -1495,14 +1487,6 @@
|
|||||||
"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}",
|
||||||
@@ -2263,14 +2247,6 @@
|
|||||||
"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.",
|
||||||
@@ -2743,14 +2719,6 @@
|
|||||||
"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",
|
||||||
@@ -3822,5 +3790,13 @@
|
|||||||
"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,6 +10,7 @@ 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,
|
||||||
@@ -144,7 +145,26 @@ 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")
|
||||||
@@ -174,6 +194,7 @@ 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(
|
||||||
@@ -189,6 +210,7 @@ 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")
|
||||||
@@ -213,6 +235,7 @@ 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,
|
||||||
@@ -225,6 +248,7 @@ 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")
|
||||||
@@ -248,6 +272,7 @@ 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")
|
||||||
@@ -270,6 +295,7 @@ 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")
|
||||||
@@ -292,3 +318,32 @@ 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,7 +40,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -231,13 +230,6 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ 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,6 +7,7 @@ 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
|
||||||
@@ -111,9 +112,8 @@ 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.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.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,9 +158,8 @@ 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.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.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)),
|
||||||
@@ -203,9 +202,8 @@ 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.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.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)),
|
||||||
@@ -289,3 +287,125 @@ 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()
|
||||||
|
|||||||
@@ -1,581 +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"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -114,6 +114,49 @@ class TestInstallAnsibleCollections:
|
|||||||
_install_ansible_collections(".venv/bin")
|
_install_ansible_collections(".venv/bin")
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("tenacity.nap.time.sleep")
|
||||||
|
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
||||||
|
@patch("devx.tools.setup._run")
|
||||||
|
def test_retries_on_transient_failure(
|
||||||
|
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""ansible-galaxy install should retry on transient network errors."""
|
||||||
|
import subprocess as _subprocess
|
||||||
|
|
||||||
|
req = tmp_path / "ansible" / "requirements.yml"
|
||||||
|
req.parent.mkdir(parents=True)
|
||||||
|
req.write_text("collections: []")
|
||||||
|
# First call fails (timeout), second succeeds
|
||||||
|
mock_run.side_effect = [
|
||||||
|
_subprocess.CalledProcessError(1, ["ansible-galaxy", "collection", "install"]),
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
with patch("devx.tools.setup.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.__str__ = lambda _: str(req)
|
||||||
|
_install_ansible_collections(".venv/bin")
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
@patch("tenacity.nap.time.sleep")
|
||||||
|
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
||||||
|
@patch("devx.tools.setup._run")
|
||||||
|
def test_exhausts_retries_then_raises(
|
||||||
|
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""After 3 attempts, the error should propagate."""
|
||||||
|
import subprocess as _subprocess
|
||||||
|
|
||||||
|
req = tmp_path / "ansible" / "requirements.yml"
|
||||||
|
req.parent.mkdir(parents=True)
|
||||||
|
req.write_text("collections: []")
|
||||||
|
mock_run.side_effect = _subprocess.CalledProcessError(1, ["ansible-galaxy"])
|
||||||
|
with patch("devx.tools.setup.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.__str__ = lambda _: str(req)
|
||||||
|
with pytest.raises(_subprocess.CalledProcessError):
|
||||||
|
_install_ansible_collections(".venv/bin")
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
class TestConfigureTeaLogin:
|
class TestConfigureTeaLogin:
|
||||||
@patch("devx.tools.setup.shutil.which", return_value=None)
|
@patch("devx.tools.setup.shutil.which", return_value=None)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class TestInstallInImage:
|
|||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
cmd = mock_run.call_args[0][0]
|
cmd = mock_run.call_args[0][0]
|
||||||
assert "--no-cache-dir" in cmd
|
assert "--no-cache-dir" in cmd
|
||||||
|
assert "--no-deps" in cmd
|
||||||
assert "-e" in cmd
|
assert "-e" in cmd
|
||||||
assert "." in cmd
|
assert "." in cmd
|
||||||
# No extras → spec is "."
|
# No extras → spec is "."
|
||||||
|
|||||||
Reference in New Issue
Block a user