Compare commits

...
29 Commits
Author SHA1 Message Date
devx-ci-bot 6aa1933dbd release: v0.50.4 [skip ci] 2026-08-12 18:16:14 +00:00
emil 08f38f3635 DEVX-156: fix: add tenacity retry to install_tools._download for transient network failures
Post-merge / detect-and-configure (push) Successful in 16s
Post-merge / release-and-maintain (push) Successful in 1m21s
2026-08-12 18:15:25 +00:00
gitea-actions-bot fc5e4634dd chore: update badge URLs to commit 1b620bc1 [skip ci] 2026-08-12 17:39:15 +00:00
devx-ci-bot 615d51335d release: v0.50.3 [skip ci] 2026-08-12 17:38:37 +00:00
emil 48596627d5 DEVX-155: refactor: extract wait_for_checks, consolidate ansible_checks, deprecate ci/discover_runners
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-08-12 17:37:49 +00:00
gitea-actions-bot b6f93cc2a8 chore: update badge URLs to commit 86408e58 [skip ci] 2026-08-12 13:26:04 +00:00
devx-ci-bot 77dc4cc22a release: v0.50.2 [skip ci] 2026-08-12 13:25:20 +00:00
emil 7dda7e5a44 DEVX-153: fix: run molecule destroy on test failure to clean up containers
Post-merge / detect-and-configure (push) Successful in 18s
Post-merge / release-and-maintain (push) Successful in 1m15s
2026-08-12 13:24:25 +00:00
gitea-actions-bot 7daee73ceb chore: update badge URLs to commit 5aa3dd2f [skip ci] 2026-08-12 13:17:52 +00:00
devx-ci-bot 06b11617ce release: v0.50.1 [skip ci] 2026-08-12 13:16:50 +00:00
emil e0ccfd6a11 DEVX-154: fix: configure git remote with CI token for post-merge push
Post-merge / detect-and-configure (push) Successful in 57s
Post-merge / release-and-maintain (push) Successful in 1m40s
2026-08-12 13:15:10 +00:00
devx-ci-bot 18632bd543 release: v0.50.0 [skip ci] 2026-08-12 12:44:32 +00:00
emil 1fc7a03c1a DEVX-152: feat: sync missing features from v0.49.x line to master
Post-merge / detect-and-configure (push) Successful in 22s
Post-merge / release-and-maintain (push) Failing after 37s
2026-08-12 12:43:37 +00:00
gitea-actions-bot 808e7a2e42 chore: update badge URLs to commit e463f9a5 [skip ci] 2026-08-07 21:03:05 +00:00
devx-ci-bot dd8e6c69e9 release: v0.49.5 [skip ci] 2026-08-07 21:02:12 +00:00
emil ccb7023965 DEVX-151: perf: skip dep resolution in setup-image with --no-deps
Post-merge / detect-and-configure (push) Successful in 36s
Post-merge / release-and-maintain (push) Successful in 1m21s
2026-08-07 21:01:06 +00:00
gitea-actions-bot 7dd15f1461 chore: update badge URLs to commit 8c02351c [skip ci] 2026-08-07 20:44:08 +00:00
devx-ci-bot d4e4621fa1 release: v0.49.4 [skip ci] 2026-08-07 20:43:19 +00:00
emil a6f814c446 DEVX-150: fix: add container.credentials for private registry auth
Post-merge / detect-and-configure (push) Successful in 25s
Post-merge / release-and-maintain (push) Successful in 1m16s
2026-08-07 20:42:18 +00:00
gitea-actions-bot 04aa5acb1f chore: update badge URLs to commit 40269e2e [skip ci] 2026-08-07 20:34:03 +00:00
devx-ci-bot 6973f9d851 release: v0.49.3 [skip ci] 2026-08-07 20:33:16 +00:00
emil 2669a0ea73 DEVX-149: fix: retry ansible-galaxy collection install on transient timeouts
Post-merge / detect-and-configure (push) Successful in 5m50s
Post-merge / release-and-maintain (push) Successful in 1m14s
2026-08-07 20:26:52 +00:00
gitea-actions-bot 03f057b55a chore: update badge URLs to commit b534b155 [skip ci] 2026-08-07 15:37:23 +00:00
devx-ci-bot 706d6dafe0 release: v0.49.2 [skip ci] 2026-08-07 15:36:28 +00:00
gitea-adminandemil 03ddce427c DEVX-148: fix: add fallback URL for tea download
Post-merge / detect-and-configure (push) Successful in 2m1s
Post-merge / release-and-maintain (push) Successful in 1m24s
Co-authored-by: oblachno Admin <admin@oblachno.oblachno.fyi>
2026-08-07 15:33:54 +00:00
gitea-actions-bot 9642d6884c chore: update badge URLs to commit 3f33ebe6 [skip ci] 2026-08-07 14:32:31 +00:00
devx-ci-bot b2074d6635 release: v0.49.1 [skip ci] 2026-08-07 14:31:37 +00:00
gitea-adminandemil a6dddf25e7 DEVX-147: fix: add container images to build-images workflow
Post-merge / detect-and-configure (push) Successful in 3m17s
Post-merge / release-and-maintain (push) Successful in 1m27s
Co-authored-by: oblachno Admin <admin@oblachno.oblachno.fyi>
2026-08-07 14:27:45 +00:00
gitea-actions-bot 01130a7385 chore: update badge URLs to commit 58fdb2b6 [skip ci] 2026-08-07 14:23:22 +00:00
51 changed files with 8416 additions and 3929 deletions
+10
View File
@@ -32,6 +32,11 @@ concurrency:
jobs:
build-and-push:
runs-on: docker
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
outputs:
is-release: ${{ steps.check.outputs.is-release }}
@@ -115,6 +120,11 @@ jobs:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
runs-on: docker
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
steps:
- uses: actions/checkout@v4
+11 -3
View File
@@ -18,7 +18,11 @@ jobs:
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
validate:
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
defaults:
run:
@@ -46,7 +50,7 @@ jobs:
- name: Check unit test speed
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
DEVX_DOC_COVERAGE_STRICT: "1"
@@ -140,7 +144,11 @@ jobs:
github.event_name == 'pull_request' &&
needs.validate.result == 'success'
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
defaults:
run:
+13 -2
View File
@@ -35,7 +35,11 @@ env:
jobs:
detect-and-configure:
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
defaults:
run:
@@ -99,7 +103,11 @@ jobs:
needs: [detect-and-configure]
if: always() && needs.detect-and-configure.result == 'success'
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
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
@@ -117,9 +125,12 @@ jobs:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release
- name: Configure git
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi"
git remote set-url origin "https://devx-ci-bot:${CI_GITEA_API_TOKEN}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git"
# --- release + publish (only if user-facing changes, not a release commit) ---
- name: Run release
id: release-tag
+1 -1
View File
@@ -59,7 +59,7 @@ repos:
- id: check-test-speed
name: unit test speed check
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
language: system
types: [python]
pass_filenames: false
+17 -3
View File
@@ -98,7 +98,9 @@ src/devx/
│ ├── record_deployed_tag.py # Record deployed tag to Gitea repo variable
│ ├── cancel_superseded_runs.py # Cancel in-flight CI runs for the same PR branch
│ ├── check_workflow_artifact_deps.py # Verify artifact download jobs depend on upload jobs
── check_workflow_tofu_init.py # Verify tofu-state jobs have a tofu-init step
── check_workflow_tofu_init.py # Verify tofu-state jobs have a tofu-init step
│ ├── discover_runners.py # Deprecated wrapper → molecule/discover_runners
│ └── wait_for_checks.py # Poll Gitea Actions for job completion (replaces inline shell polling)
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
@@ -122,8 +124,19 @@ src/devx/
│ ├── pr_label.py # Add labels to PRs (idempotent)
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ ├── check_docker_init.py # Check Docker Compose services with healthchecks have init: true
│ ├── check_ansible_set_fact_to_json.py # Check set_fact tasks don't misuse to_json
│ ├── check_ansible_set_fact_to_json.py # Thin wrapper → ansible_checks/set_fact_to_json
│ ├── check_alert_rules.py # Validate Prometheus alert rules with promtool
│ ├── check_ansible_no_log.py # Thin wrapper → ansible_checks/no_log
│ ├── check_ansible_patterns.py # Thin wrapper → ansible_checks/patterns
│ ├── check_jinja_expr.py # Thin wrapper → ansible_checks/jinja_expr
│ ├── check_ansible_no_state_absent_on_db.py # Thin wrapper → ansible_checks/no_state_absent_on_db
│ ├── ansible_checks/ # Composable Ansible check subpackage (canonical implementations)
│ │ ├── _shared.py # AnsibleFileFinder, AnsibleYAMLParser, ViolationReporter
│ │ ├── no_log.py # Check missing no_log on secret-handling tasks
│ │ ├── patterns.py # Detect dangerous failure-masking patterns
│ │ ├── set_fact_to_json.py # Check set_fact tasks don't misuse to_json
│ │ ├── no_state_absent_on_db.py # Prevent state:absent on DB paths
│ │ └── jinja_expr.py # Validate Jinja2 expressions in Ansible files
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
├── utils/ # Shared utilities (reusable across projects)
@@ -139,10 +152,11 @@ src/devx/
│ ├── ui.py # say() — unified click.echo + logging output
│ └── jinja.py # Jinja2 environment helpers + Ansible-compatible filters
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── discover_runners.py # Dynamic Gitea runner discovery (canonical; ci/discover_runners is a deprecated wrapper)
├── 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_changed.py # Detect which Ansible roles changed and output molecule scenarios
├── start_docker.py # Ensure Docker daemon is running for molecule tests
└── platforms.py # Supported molecule platforms
```
+95
View File
@@ -2,6 +2,101 @@
All notable changes to this project will be documented in this file.
## [0.50.4] - 2026-08-12
### Bug Fixes
- Add tenacity retry to install_tools._download for transient network failures
## [0.50.3] - 2026-08-12
### Refactor
- Extract wait_for_checks, consolidate ansible_checks, deprecate ci/discover_runners
## [0.50.2] - 2026-08-12
### Bug Fixes
- Run molecule destroy on test failure to clean up containers
## [0.50.1] - 2026-08-12
### Bug Fixes
- Configure git remote with CI token for post-merge push
## [0.50.0] - 2026-08-12
### Features
- Sync missing features from v0.49.x line to master
## [0.49.5] - 2026-08-07
### Performance
- Skip dep resolution in setup-image with --no-deps
## [0.49.4] - 2026-08-07
### Bug Fixes
- Add container.credentials for private registry auth
## [0.49.3] - 2026-08-07
### Bug Fixes
- Retry ansible-galaxy collection install on transient timeouts
## [0.49.2] - 2026-08-07
### Bug Fixes
- Add fallback URL for tea download
## [0.49.1] - 2026-08-07
### Bug Fixes
- Add container images to build-images workflow
## [0.49.0] - 2026-08-07
### Features
- Add --include-roles and --exclude-roles to distribute_molecule
## [0.48.0] - 2026-07-22
### Features
- Extract reusable components from infra and grm into devx
## [0.49.5] - 2026-08-07
### Performance
- Skip dep resolution in setup-image with --no-deps
## [0.49.4] - 2026-08-07
### Bug Fixes
- Add container.credentials for private registry auth
## [0.49.3] - 2026-08-07
### Bug Fixes
- Retry ansible-galaxy collection install on transient timeouts
## [0.49.2] - 2026-08-07
### Bug Fixes
- Add fallback URL for tea download
## [0.49.1] - 2026-08-07
### Bug Fixes
- Add container images to build-images workflow
## [0.49.0] - 2026-08-07
### Features
+1 -1
View File
@@ -66,7 +66,7 @@ setup-release: $(VENV)/bin/activate .env
# 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.
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
install-hooks:
+9 -14
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.49.0",
"devx>=0.50.4",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.49.0"`) or use a version constraint
> (for example, `"devx>=0.49.0,<0.50"`).
> `dependencies` (for example, `"devx==0.50.4"`) or use a version constraint
> (for example, `"devx>=0.50.4,<0.51"`).
### 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-platforms # list platforms
# Run molecule tests with cross-runner fail-fast
python -m devx.molecule.molecule_ci_guard pair1 pair2
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
# Run all molecule scenarios locally (sequential)
python -m devx.molecule.molecule_all
python -m devx.molecule.molecule_all --bin .venv/bin
@@ -303,7 +299,6 @@ devx --version
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
| `devx molecule guard` | Run molecule tests with CI failure polling |
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
in the wiki for full command documentation with examples.
@@ -0,0 +1,108 @@
# ADR-0002: Ansible Check Tool Consolidation and wait_for_checks Extraction
Date: 2026-08-12
Status: Accepted
## Context
The devx package had two categories of code duplication and inline
workflow logic that were hard to test and maintain:
### 1. Ansible Check Tools — Duplicated Boilerplate
Five Ansible check tools (`check_ansible_no_log`,
`check_ansible_patterns`, `check_ansible_set_fact_to_json`,
`check_ansible_no_state_absent_on_db`, `check_jinja_expr`) each
implemented their own file discovery, YAML parsing, task iteration, and
violation reporting logic. While the check logic differed, the
supporting infrastructure was copy-pasted across all five modules:
- `find_task_files()` — glob YAML files, skip molecule
- YAML multi-document parsing with error handling
- Task iteration (bare lists, play dicts with `tasks`/`pre_tasks`/`post_tasks`/`handlers`, nested `block` tasks)
- Violation formatting (`path:line — message`)
This made it difficult to add new checks (each new tool repeated the
boilerplate) and risky to change shared behavior (fixes had to be
applied to all five modules independently).
### 2. Inline Job Polling in Workflow YAML
The `grm` repository's `ci.yml` workflow contained ~25 lines of inline
shell + Python polling logic to wait for the `molecule-tests` job to
complete before the auto-merge step. This logic:
- Was not testable (embedded in workflow YAML)
- Duplicated the Gitea API client pattern already used elsewhere
- Had no timeout handling, no error reporting, no retry logic
- Could not be reused by other repositories
### 3. Duplicate discover_runners Modules
`devx.ci.discover_runners` and `devx.molecule.discover_runners` were
near-identical modules. The `ci/` version had better error logging
(warnings on non-200 responses, 403 suppression for instance-level
queries), while the `molecule/` version silently swallowed errors.
Both were imported by different workflows, making it unclear which was
canonical.
## Decision
### 1. Composable `ansible_checks/` Subpackage
Consolidate the five Ansible check tools into a
`devx.tools.ansible_checks/` subpackage with shared utilities:
- `_shared.py``AnsibleFileFinder`, `AnsibleYAMLParser`,
`ViolationReporter` classes providing composable helpers
- `no_log.py`, `patterns.py`, `set_fact_to_json.py`,
`no_state_absent_on_db.py`, `jinja_expr.py` — canonical check
implementations using the shared utilities
The old modules (`check_ansible_*.py`, `check_jinja_expr.py`) remain as
**thin backward-compat wrappers** that re-export the canonical
implementation and preserve the CLI entry point. This avoids breaking
existing Makefile targets and workflow references.
**Composition over inheritance**: each check module picks the helpers it
needs. Tools that don't parse YAML (for example line-based scanners) can skip
`AnsibleYAMLParser` entirely.
### 2. Extracted `wait_for_checks` Module
Extract the inline polling logic into `devx.ci.wait_for_checks`:
- Polls the Gitea API for job completion status
- Configurable job name prefix, timeout, poll interval
- Exit codes: 0 (success), 1 (failure), 2 (timeout), 3 (API error)
- `--require-success/--no-require-success` flag for flexibility
- 100% test coverage with mocked API responses
This replaces the inline shell polling in `grm` `ci.yml` with a
reusable, testable Python module.
### 3. Deprecated `ci/discover_runners` Wrapper
Merge the `ci/discover_runners` implementation (with its better error
logging) into `molecule/discover_runners` as the canonical version.
Make `ci/discover_runners` a deprecated wrapper that:
- Re-exports all public symbols from `molecule.discover_runners`
- Emits a `DeprecationWarning` when run as `__main__`
- Preserves backward compatibility for existing workflow references
New code should import from `devx.molecule.discover_runners` directly.
## Consequences
- **New checks are easier to write**: import `_shared` helpers, implement
only the check-specific logic
- **Shared behavior can be fixed in one place**: file discovery, YAML
parsing, violation formatting
- **Workflow polling is testable**: `wait_for_checks` has 26 unit tests
covering success, failure, timeout, and API error scenarios
- **Backward compatibility preserved**: all existing Makefile targets,
workflow references, and test imports continue to work via wrappers
- **Migration path is gradual**: new code uses the subpackage; old code
can migrate at its own pace; wrappers can be removed in a future
release once all references are updated
+11 -11
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b55c2d2fdd2e48812530415de35b1017a6f86f1e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1b620bc1ac52cad884e501093d4fba5d3968ce67/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.49.0",
"devx>=0.50.4",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.49.0"` or `"devx>=0.49.0,<0.50"`.
Pin a specific version if needed: `"devx==0.50.4"` or `"devx>=0.50.4,<0.51"`.
### 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,
configure_repo, generate_badges, generate_cliff_config, install_checkmake
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
start_docker, platforms
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
platforms
See [Architecture](Architecture) for the full package structure, module
descriptions, design principles, and data flow diagrams.
@@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups:
- `devx ci <command>` — CI/CD automation (17 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.
+3 -1
View File
@@ -3,5 +3,7 @@
"user/getting-started.md": "Getting-Started",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
"tech/ci-cd-workflow.md": "CI-CD-Workflow",
"decisions/0001-test-isolation-pytest-plugin-and-shift-left-quality-gates.md": "ADR-0001-Test-Isolation",
"decisions/0002-ansible-check-consolidation-and-wait-for-checks.md": "ADR-0002-Ansible-Check-Consolidation"
}
+22 -5
View File
@@ -33,7 +33,8 @@ src/devx/
│ ├── notify_failure.py # Create Gitea issues on CI failures
│ ├── distribute_files.py # Distribute files across parallel runners
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── discover_runners.py # Dynamic Gitea runner discovery
│ ├── discover_runners.py # Deprecated wrapper → molecule/discover_runners
│ ├── wait_for_checks.py # Poll Gitea Actions for job completion
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
@@ -48,7 +49,7 @@ src/devx/
│ └── install_checkmake.py # Install checkmake (Makefile linter)
└── molecule/ # Optional molecule testing helpers (Ansible projects)
├── __init__.py
├── discover_runners.py # Dynamic Gitea runner discovery
├── discover_runners.py # Dynamic Gitea runner discovery (canonical)
├── 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
@@ -285,13 +286,24 @@ Click commands from `cli.py` and verifies each has documentation in
`architecture.md` and CI scripts in `ci-cd-workflow.md`. Supports
`--fail-on-missing` to enforce 100% coverage.
### `discover_runners.py`
### `discover_runners.py` (deprecated wrapper)
> **Deprecated:** Use `devx.molecule.discover_runners` instead. This
> module is a thin wrapper that re-exports the canonical implementation.
Discovers available Gitea Actions runners at three levels: repository,
organization, and instance (administrator). Falls back to the `MOLECULE_RUNNERS` repo
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
array for use as a dynamic matrix in Gitea Actions.
### `wait_for_checks.py`
Polls the Gitea Actions API for job completion status. Used by auto-merge
jobs that need to wait for parallel jobs (for example molecule-tests) before
proceeding. Replaces inline shell polling in workflow YAML with a
reusable, testable Python module. Exit codes: 0 (success), 1 (job
failure), 2 (timeout), 3 (API error or no matching jobs).
### `distribute_files.py`
Distributes files matching a glob pattern across N parallel runners
@@ -396,8 +408,13 @@ Intended for local development; CI uses the parallel matrix instead.
### `molecule/discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. Same logic as
`devx.ci.discover_runners` but intended for molecule-specific workflows.
Discovers available Gitea Actions runners for molecule tests. This is the
canonical implementation; `devx.ci.discover_runners` is a deprecated wrapper
that re-exports from this module. Queries runners at repository,
organization, and instance (administrator) levels, with warnings logged
to stderr on non-200 responses (except 403 on instance-level, which is
expected without admin scope). Falls back to `MOLECULE_RUNNERS` env var
or `DEFAULT_MAX_RUNNERS` (3).
### `start_docker.py`
+31
View File
@@ -83,6 +83,11 @@ devx ci detect-release-commit
### `devx ci discover-runners`
> **Deprecated:** Use `devx molecule discover-runners` instead. This
> command is a thin wrapper that re-exports the canonical implementation
> from `devx.molecule.discover_runners`. It will be removed in a future
> release.
Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at repository, organization, and
instance (administrator) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
@@ -315,6 +320,32 @@ devx ci validate-commit-msg commit-msg.txt --branch master
Options:
- `--branch <branch>` — override branch detection (for CI use)
### `devx ci wait-for-checks`
Wait for Gitea Actions jobs to complete by polling the API. Used by
auto-merge jobs that need to wait for parallel jobs (for example molecule-tests)
before proceeding. Replaces inline shell polling in workflow YAML with
a reusable, testable Python module.
Exit codes:
- `0` — all matching jobs completed successfully
- `1` — one or more matching jobs failed (when `--require-success` is set)
- `2` — timeout reached before all jobs completed
- `3` — API error or no matching jobs found
```bash
devx ci wait-for-checks --job-name molecule-tests --repo oblachno-oss/grm
devx ci wait-for-checks --job-name molecule-tests --timeout 1200 --poll-interval 10
devx ci wait-for-checks --job-name molecule-tests --no-require-success
```
Options:
- `--job-name <prefix>` — job name prefix to match (required)
- `--repo <owner/name>` — repository (default: `$GITHUB_REPOSITORY`)
- `--timeout <seconds>` — max wait time (default: 1200 = 20 min)
- `--poll-interval <seconds>` — seconds between polls (default: 10)
- `--require-success / --no-require-success` — exit 1 if a job failed (default: yes)
### `devx ci cancel-superseded-runs`
Cancel in-flight CI runs for the same PR branch when a new push triggers
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.49.0",
"devx>=0.50.4",
]
[project.optional-dependencies]
dev = [
"devx>=0.49.0",
"devx>=0.50.4",
]
```
+7 -2
View File
@@ -64,11 +64,16 @@ molecule = [
"ansible-core==2.21.1",
]
# 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 = [
"ansible-core==2.21.1",
"boto3==1.43.37",
"boto3==1.43.44",
"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)
dev = [
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.49.0"
__version__ = "0.50.4"
+28 -173
View File
@@ -1,19 +1,18 @@
#!/usr/bin/env python3
"""Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at three levels:
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
2. Organization level: GET /orgs/{org}/actions/runners
3. Instance (admin) level: GET /admin/actions/runners
.. deprecated:: Phase 1c
Use :mod:`devx.molecule.discover_runners` instead. This module is a
thin wrapper that re-exports the canonical implementation from
:mod:`devx.molecule.discover_runners` for backward compatibility
with existing workflow references and Makefile targets.
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
Outputs:
- ``--count``: prints the number of available runners
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
dynamic matrix in Gitea Actions
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
The canonical implementation lives in
:mod:`devx.molecule.discover_runners` because runner discovery is
primarily used by the molecule test distribution pipeline. CI
workflows that still reference ``python -m devx.ci.discover_runners``
will continue to work via this wrapper, but new code should import
from :mod:`devx.molecule.discover_runners` directly.
Usage:
python3 -m devx.ci.discover_runners --owner oblachno-oss --repo devx
@@ -23,172 +22,28 @@ Usage:
from __future__ import annotations
import json
import os
import sys
import warnings
import click
import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
"""Query the Gitea API for registered runners at all levels.
Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to
what we can see. Fallbacks are logged to stderr for debugging.
"""
headers = {"Authorization": f"token {token}"}
total = 0
# 1. Repository-level runners
try:
r = requests.get(
f"{api_url}/repos/{owner}/{repo}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners
try:
r = requests.get(
f"{api_url}/orgs/{owner}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope)
try:
r = requests.get(
f"{api_url}/admin/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total
def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
"""Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default.
"""
# Try API query if we have a token
if token:
api_count = query_runners(api_url, token, owner, repo)
if api_count > 0:
return api_count
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
env_count = os.environ.get("MOLECULE_RUNNERS")
if env_count:
try:
count = int(env_count)
if count > 0:
return count
except ValueError:
pass
# Fall back to default
return DEFAULT_MAX_RUNNERS
def generate_indices(count: int) -> list[str]:
"""Generate a list of runner indices ["1", "2", ..., "N"].
Uses 1-based string indices because Gitea Actions renders
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
expressions, causing --runner-index to be passed without a value.
The distribute_molecule.py script converts these back to 0-based
internally.
"""
return [str(i + 1) for i in range(count)]
@click.command()
@click.option("--owner", default=None, help="Repository owner (for API query).")
@click.option("--repo", default=None, help="Repository name (for API query).")
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
from devx.molecule.discover_runners import ( # noqa: F401 — re-exported for backward compat
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
main,
query_runners,
)
def main(
owner: str | None,
repo: str | None,
output_count: bool,
output_indices: bool,
github_output: bool,
) -> None:
try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
_DEPRECATION_MSG = (
"devx.ci.discover_runners is deprecated; use devx.molecule.discover_runners instead. "
"This wrapper will be removed in a future release."
)
count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count)
if github_output:
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(_("Runner count: {count}", count=count))
click.echo(_("Runner indices: {indices}", indices=indices))
return
if output_count:
click.echo(str(count))
return
if output_indices:
click.echo(json.dumps(indices))
return
# Default: output both as key=value pairs for CI consumption
click.echo(_("count={count}", count=count))
click.echo(_("indices={indices}", indices=json.dumps(indices)))
def _emit_deprecation_warning() -> None:
"""Emit a DeprecationWarning when this module is imported for CLI use."""
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
if __name__ == "__main__": # pragma: no cover
main()
_emit_deprecation_warning()
sys.exit(main())
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Wait for Gitea Actions jobs to complete.
Polls the Gitea API for job completion status. Used by auto-merge
jobs that need to wait for molecule-tests or other parallel jobs
before proceeding.
Exits:
0 all matching jobs completed successfully
1 one or more matching jobs failed
2 timeout reached before all jobs completed
3 API error or job not found
Usage:
python3 -m devx.ci.wait_for_checks \\
--job-name molecule-tests \\
--repo oblachno-oss/grm \\
--timeout 1200 \\
--poll-interval 10
"""
from __future__ import annotations
import os
import sys
import time
import click
import requests
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tokens import get_ci_token
def query_job_status(api_url: str, token: str, repo: str, job_name_prefix: str) -> list[dict]:
"""Query the Gitea API for the status of jobs matching *job_name_prefix*.
Fetches the most recent pull_request runs (up to 3) and inspects
their jobs. Returns a list of ``{"name": str, "status": str,
"conclusion": str | None}`` dicts for jobs whose name starts with
*job_name_prefix*. On API errors, logs a warning to stderr and
returns an empty list callers treat this as "no information yet"
and retry on the next poll.
"""
headers = {"Authorization": f"token {token}"}
matches: list[dict] = []
try:
r = requests.get(
f"{api_url}/repos/{repo}/actions/runs",
headers=headers,
params={"limit": 5, "event": "pull_request"},
timeout=10,
)
if r.status_code != 200:
click.echo(
_("Warning: actions runs query returned HTTP {status}", status=r.status_code),
err=True,
)
return []
runs = r.json()
if isinstance(runs, dict):
runs = runs.get("runs", [])
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: actions runs query failed: {error}", error=e), err=True)
return []
for run in runs[:3]:
run_id = run.get("id")
if run_id is None:
continue
try:
jr = requests.get(
f"{api_url}/repos/{repo}/actions/runs/{run_id}/jobs",
headers=headers,
timeout=10,
)
if jr.status_code != 200:
click.echo(
_(
"Warning: jobs query for run {run_id} returned HTTP {status}",
run_id=run_id,
status=jr.status_code,
),
err=True,
)
continue
jobs = jr.json()
if isinstance(jobs, dict):
jobs = jobs.get("jobs", [])
except (requests.RequestException, ValueError) as e:
click.echo(
_("Warning: jobs query for run {run_id} failed: {error}", run_id=run_id, error=e),
err=True,
)
continue
for job in jobs:
name = job.get("name", "")
if name.startswith(job_name_prefix):
matches.append(
{
"name": name,
"status": job.get("status", "unknown"),
"conclusion": job.get("conclusion"),
}
)
return matches
def poll_until_complete(
api_url: str,
token: str,
repo: str,
job_name: str,
timeout: int,
interval: int,
require_success: bool = True,
) -> int:
"""Poll *query_job_status* until all matching jobs complete or *timeout*.
Returns:
0 all matching jobs completed successfully (or any completed, when
*require_success* is False)
1 at least one matching job completed with a non-success conclusion
(only when *require_success* is True)
2 *timeout* reached before all matching jobs completed
3 no matching jobs found at all within *timeout*
"""
deadline = time.monotonic() + timeout
found_any = False
while time.monotonic() < deadline:
jobs = query_job_status(api_url, token, repo, job_name)
if jobs:
found_any = True
all_completed = all(j["status"] == "completed" for j in jobs)
if all_completed:
if require_success and any(j["conclusion"] != "success" for j in jobs):
click.echo(
_("Job(s) completed with non-success conclusion: {jobs}", jobs=jobs),
err=True,
)
return 1
click.echo(_("All matching jobs completed successfully: {jobs}", jobs=jobs))
return 0
# Not all completed (or no jobs yet) — sleep and retry.
time.sleep(min(interval, max(0, deadline - time.monotonic())))
if not found_any:
click.echo(_("No matching jobs found for prefix '{prefix}' within timeout.", prefix=job_name), err=True)
return 3
click.echo(_("Timeout reached waiting for jobs matching '{prefix}'.", prefix=job_name), err=True)
return 2
@click.command()
@click.option("--job-name", required=True, help="Job name prefix to match (e.g. 'molecule-tests').")
@click.option(
"--repo",
default=None,
help="Repository as owner/name (default: $GITHUB_REPOSITORY env var).",
)
@click.option("--timeout", type=int, default=1200, help="Max seconds to wait (default: 1200 = 20 min).")
@click.option("--poll-interval", "interval", type=int, default=10, help="Seconds between polls (default: 10).")
@click.option(
"--require-success/--no-require-success",
default=True,
help="Exit 1 if a matched job failed (default: yes).",
)
def main(job_name: str, repo: str | None, timeout: int, interval: int, require_success: bool) -> None:
"""Wait for Gitea Actions jobs matching --job-name to complete."""
if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "")
if not repo or "/" not in repo:
raise click.ClickException(_("--repo is required (or set GITHUB_REPOSITORY=owner/name)"))
if timeout <= 0:
raise click.ClickException(_("--timeout must be positive"))
if interval <= 0:
raise click.ClickException(_("--poll-interval must be positive"))
try:
token = get_ci_token()
except click.ClickException as e:
click.echo(str(e), err=True)
sys.exit(3)
click.echo(
_(
"Waiting for jobs matching '{prefix}' in {repo} (timeout={timeout}s, interval={interval}s)",
prefix=job_name,
repo=repo,
timeout=timeout,
interval=interval,
)
)
code = poll_until_complete(
GITEA_API_URL,
token,
repo,
job_name,
timeout,
interval,
require_success=require_success,
)
sys.exit(code)
if __name__ == "__main__": # pragma: no cover
main()
+7
View File
@@ -158,6 +158,13 @@ def ci_validate_commit_msg(args: tuple[str, ...]) -> None:
_run_module("devx.ci.validate_commit_msg", list(args))
@ci.command("wait-for-checks")
@click.argument("args", nargs=-1)
def ci_wait_for_checks(args: tuple[str, ...]) -> None:
"""Wait for Gitea Actions jobs to complete (polls API)."""
_run_module("devx.ci.wait_for_checks", list(args))
@ci.command("distribute-files")
@click.argument("args", nargs=-1)
def ci_distribute_files(args: tuple[str, ...]) -> None:
+21 -11
View File
@@ -30,6 +30,7 @@ import click
import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3
@@ -40,7 +41,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to
what we can see.
what we can see. Fallbacks are logged to stderr for debugging.
"""
headers = {"Authorization": f"token {token}"}
total = 0
@@ -55,8 +56,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
else:
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners
try:
@@ -68,8 +71,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
else:
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope)
try:
@@ -81,8 +86,13 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e:
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total
@@ -163,8 +173,8 @@ def main(
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
click.echo(_("Runner count: {count}", count=count))
click.echo(_("Runner indices: {indices}", indices=indices))
return
if output_count:
@@ -176,8 +186,8 @@ def main(
return
# Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}")
click.echo(f"indices={json.dumps(indices)}")
click.echo(_("count={count}", count=count))
click.echo(_("indices={indices}", indices=json.dumps(indices)))
if __name__ == "__main__": # pragma: no cover
+158
View File
@@ -0,0 +1,158 @@
"""Detect which Ansible roles changed and output their molecule scenarios.
Usage::
python -m devx.molecule.molecule_changed --print-targets
python -m devx.molecule.molecule_changed --base origin/master --print-roles
Outputs the list of make targets (e.g. molecule-docker-base) for roles
that have changed files vs the base ref. Used by ``make molecule-changed``
to run only the molecule scenarios affected by the current diff.
Role-to-target mapping is derived from the directory structure:
ansible/roles/<role>/ molecule-<role>
For roles with multiple scenarios (e.g. app_container has customer-apps,
nextcloud, postgres-upgrade, simple-app), the base target runs all
scenarios for that role.
Playbooks that change also trigger molecule for the roles they include.
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
trigger all scenarios.
"""
from __future__ import annotations
import subprocess # nosec B404 — used to run git, a trusted binary
from pathlib import Path
import click
REPO_ROOT = Path.cwd()
# Map role names to make targets.
ROLE_TARGET_MAP: dict[str, str] = {
"app_container": "molecule-app-container",
"app_hardening": "molecule-app-hardening",
"crowdsec": "molecule-crowdsec",
"disk_cleanup": "molecule-disk-cleanup",
"docker_base": "molecule-docker-base",
"observability": "molecule-observability",
"restore": "molecule-restore",
"sso_config": "molecule-sso-config",
"storage": "molecule-storage",
"zitadel": "molecule-zitadel",
}
# Playbooks that map to molecule scenarios (via roles they include).
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening", "sso_config"],
"ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"],
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"],
}
# Shared infrastructure that affects all molecule tests.
SHARED_PATHS = (
"ansible/ansible.cfg",
"ansible/requirements.yml",
"ansible/molecule/",
)
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
# Files inside the role have more parts, but we only need the role name.
_MIN_ROLE_PATH_PARTS = 3
def _run_git(args: list[str]) -> str: # pragma: no cover
"""Run a git command and return stdout."""
result = subprocess.run( # nosec
["git", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
return result.stdout
def get_changed_files(base: str) -> list[str]:
"""Get list of changed files vs base ref."""
for ref in [base, "master"]:
output = _run_git(["diff", "--name-only", f"{ref}...HEAD"])
if output.strip():
return sorted(output.strip().splitlines())
return []
def detect_changed_roles(changed_files: list[str]) -> set[str]:
"""Detect which roles have changed files."""
roles: set[str] = set()
for filepath in changed_files:
# Check if file is in a role directory
if filepath.startswith("ansible/roles/"):
parts = filepath.split("/")
if len(parts) >= _MIN_ROLE_PATH_PARTS:
roles.add(parts[2])
# Check if file is a playbook that maps to roles
if filepath in PLAYBOOK_ROLE_MAP:
roles.update(PLAYBOOK_ROLE_MAP[filepath])
# Check shared infrastructure — triggers all roles
for shared in SHARED_PATHS:
if filepath.startswith(shared):
return set(ROLE_TARGET_MAP.keys())
return roles
def roles_to_targets(roles: set[str]) -> list[str]:
"""Convert role names to make targets."""
targets = []
for role in sorted(roles):
target = ROLE_TARGET_MAP.get(role)
if target:
targets.append(target)
return targets
@click.command()
@click.option(
"--base",
default="origin/master",
help="Base ref to compare against (default: origin/master).",
)
@click.option(
"--print-targets",
is_flag=True,
help="Print make targets (e.g. molecule-docker-base).",
)
@click.option(
"--print-roles",
is_flag=True,
help="Print role names (default if no --print-targets).",
)
def main(base: str, print_targets: bool, print_roles: bool) -> None:
"""Detect which Ansible roles changed and output molecule scenarios."""
changed_files = get_changed_files(base)
if not changed_files:
click.echo("No changed files detected.", err=True)
return
roles = detect_changed_roles(changed_files)
if not roles:
click.echo("No molecule scenarios affected by changes.", err=True)
return
if print_targets:
for target in roles_to_targets(roles):
click.echo(target)
else:
for role in sorted(roles):
click.echo(role)
if __name__ == "__main__": # pragma: no cover
main()
+44
View File
@@ -246,18 +246,62 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
# Clean up containers left behind by the killed test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
# Clean up containers left behind by the interrupted test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1)
rc = process.returncode
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
# Run molecule destroy to clean up containers left behind by the
# failed test. Without this, containers stay running and accumulate
# on the runner, consuming disk/memory and degrading CI performance.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
+34
View File
@@ -0,0 +1,34 @@
"""Ansible check tools — composable validators for Ansible playbooks and roles.
Each check module exports a ``check_*`` function that returns a list of
violation strings. The shared utilities in :mod:`devx.tools.ansible_checks._shared`
handle file discovery, YAML parsing, and violation reporting.
The old entry points (``devx.tools.check_ansible_*``, ``devx.tools.check_jinja_expr``)
remain as thin wrappers for backward compatibility with existing Makefile
targets and workflow references.
"""
from devx.tools.ansible_checks._shared import (
DEFAULT_ANSIBLE_DIRS,
AnsibleFileFinder,
AnsibleYAMLParser,
ViolationReporter,
)
from devx.tools.ansible_checks.jinja_expr import check_jinja_expr
from devx.tools.ansible_checks.no_log import check_no_log
from devx.tools.ansible_checks.no_state_absent_on_db import check_no_state_absent_on_db
from devx.tools.ansible_checks.patterns import check_patterns
from devx.tools.ansible_checks.set_fact_to_json import check_set_fact_to_json
__all__ = [
"DEFAULT_ANSIBLE_DIRS",
"AnsibleFileFinder",
"AnsibleYAMLParser",
"ViolationReporter",
"check_jinja_expr",
"check_no_log",
"check_no_state_absent_on_db",
"check_patterns",
"check_set_fact_to_json",
]
+178
View File
@@ -0,0 +1,178 @@
"""Shared utilities for Ansible check tools.
Provides composable helpers for file discovery, YAML parsing, and
violation reporting used by the modules in :mod:`devx.tools.ansible_checks`.
Composition over inheritance: each check module picks the helpers it
needs. Tools that don't parse YAML (e.g. line-based scanners) can skip
:class:`AnsibleYAMLParser` entirely.
"""
from __future__ import annotations
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Final
import click
import yaml
from devx.i18n import _
#: Default Ansible directories scanned by checks that accept ``--ansible-dir``.
#: Immutable tuple (not a list) to avoid module-level mutable globals.
DEFAULT_ANSIBLE_DIRS: Final[tuple[str, ...]] = ("ansible/roles", "ansible/playbooks")
class AnsibleFileFinder:
"""File discovery helpers for Ansible YAML files."""
@staticmethod
def find_task_files(base: Path, skip_molecule: bool = True) -> list[Path]:
"""Find all YAML files under *base*, recursively.
If *base* is a single YAML file, returns ``[base]``. If *base* is
not a file or directory, returns ``[]``. When *skip_molecule* is
True, files with ``molecule`` in their path parts are excluded.
"""
if base.is_file() and base.suffix in (".yml", ".yaml"):
return [base]
if not base.is_dir():
return []
files: list[Path] = []
for f in sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml")):
if skip_molecule and "molecule" in f.parts:
continue
files.append(f)
return files
@staticmethod
def find_yaml_files(base: Path, skip_molecule: bool = True) -> list[Path]:
"""Find YAML files under *base* using ``glob`` (non-recursive rglob).
Unlike :meth:`find_task_files`, this uses ``base.glob("**/*.yml")``
and does not check the suffix when *base* is a single file (any
file is accepted). Used by the Jinja expression checker which
scans all YAML files including defaults/handlers.
"""
if base.is_file():
return [base]
files: list[Path] = []
for pattern in ("**/*.yml", "**/*.yaml"):
files.extend(base.glob(pattern))
if skip_molecule:
return [f for f in files if "molecule" not in f.parts]
return files
@staticmethod
def find_task_and_playbook_files(base: Path, skip_molecule: bool = True) -> list[Path]:
"""Find task files (``tasks/*.yml``) and playbook files (``playbooks/*.yml``).
Used by the no_log checker which scans role task files and
top-level playbook files. When *skip_molecule* is True, molecule
scenario files are excluded.
"""
task_files = list(base.rglob("tasks/*.yml")) + list(base.rglob("tasks/*.yaml"))
task_files += list(base.glob("playbooks/*.yml")) + list(base.glob("playbooks/*.yaml"))
if skip_molecule:
task_files = [f for f in task_files if "molecule" not in f.parts]
return sorted(task_files)
class AnsibleYAMLParser:
"""YAML parsing helpers for Ansible files."""
@staticmethod
def parse_file(content: str) -> list[dict]:
"""Parse multi-document YAML from *content*.
Returns a list of non-None documents. On ``YAMLError`` or
``OSError``, returns an empty list (the caller skips the file).
"""
try:
docs = list(yaml.safe_load_all(content))
except (yaml.YAMLError, OSError):
return []
return [d for d in docs if d]
@staticmethod
def iter_tasks(doc: dict | list) -> Iterator[tuple[dict, int]]:
"""Yield ``(task_dict, line_number)`` tuples from a YAML document.
Handles:
- Bare task lists (role tasks files): ``[task1, task2, ...]``
- Play dicts with ``hosts`` key: iterates ``tasks``,
``pre_tasks``, ``post_tasks``, ``handlers`` sections
- Nested ``block`` tasks
The line number is the 1-based index within the task section
(not the file line number callers use it for display only).
"""
if isinstance(doc, list):
for i, item in enumerate(doc):
if isinstance(item, dict):
if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers")):
yield from AnsibleYAMLParser._iter_play_sections(item)
else:
yield item, i + 1
block = item.get("block")
if isinstance(block, list):
for j, bt in enumerate(block):
if isinstance(bt, dict):
yield bt, i + j + 1
elif isinstance(doc, dict):
yield from AnsibleYAMLParser._iter_play_sections(doc)
@staticmethod
def _iter_play_sections(doc: dict) -> Iterator[tuple[dict, int]]:
"""Yield tasks from play sections (tasks, pre_tasks, post_tasks, handlers)."""
for section_key in ("tasks", "pre_tasks", "post_tasks", "handlers"):
section = doc.get(section_key)
if isinstance(section, list):
for i, task in enumerate(section):
if isinstance(task, dict):
yield task, i + 1
block = task.get("block")
if isinstance(block, list):
for j, bt in enumerate(block):
if isinstance(bt, dict):
yield bt, i + j + 1
class ViolationReporter:
"""Standardized violation formatting and reporting."""
@staticmethod
def format_violation(
filepath: Path,
repo_root: Path,
line_num: int | None,
message: str,
) -> str:
"""Format a violation as ``"{relative_path}:{line_num} — message"``.
Falls back to the full path if *filepath* is not relative to
*repo_root*. When *line_num* is None, omits the line number.
"""
try:
display_path = filepath.relative_to(repo_root)
except ValueError:
display_path = filepath
if line_num is not None:
return f"{display_path}:{line_num}{message}"
return f"{display_path}{message}"
@staticmethod
def report(violations: list[str], tool_name: str) -> None:
"""Print violations and exit with the appropriate code.
Prints ``[{tool_name}] FAIL`` or ``[{tool_name}] OK`` and exits
1 if violations are non-empty, 0 otherwise.
"""
if violations:
click.echo(_("[{tool}] FAIL: {count} violation(s) found.", tool=tool_name, count=len(violations)))
for v in violations:
click.echo(f" - {v}")
sys.exit(1)
click.echo(_("[{tool}] OK: no violations found.", tool=tool_name))
+226
View File
@@ -0,0 +1,226 @@
"""Validate Jinja2 expressions in Ansible files by rendering them.
Extracted from :mod:`devx.tools.check_jinja_expr` as part of the
Ansible check tool consolidation. The old module remains as a thin
wrapper for backward compatibility.
"""
from __future__ import annotations
import re
from pathlib import Path
from jinja2 import Environment
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from devx.tools.ansible_checks._shared import AnsibleFileFinder, ViolationReporter
REPO_ROOT = Path.cwd()
MOCK_CONTEXT: dict[str, object] = {
"now": lambda fmt=None: (
"2026-01-01T00:00:00+00:00"
if fmt
else type(
"Now",
(),
{
"timestamp": lambda self: 1735689600.0,
"strftime": lambda self, fmt: "2026-01-01T00:00:00+00:00",
},
)()
),
"ansible_date_time": {
"iso8601": "2026-01-01T00:00:00+00:00",
"epoch": "1735689600",
},
"ansible_facts": {
"service_mgr": "systemd",
"architecture": "x86_64",
"distribution_release": "noble",
"virtualization_type": "none",
"interfaces": ["eth0", "lo"],
"hostname": "test-host",
},
"ansible_host": "10.0.0.1",
"env": "staging",
"environment": "staging",
"customer_id": "test",
"zitadel_domain": "zitadel.test",
"_env_name": "staging",
"_observability_data_root": "/opt",
"skip_zitadel_stack": False,
"skip_htpasswd": False,
"skip_observability_stack": False,
"backup_enabled": True,
"app_filter": "",
"app_domain": "test.example.com",
"oidc_client_id": "test-client-id",
"oidc_client_secret": "test-secret", # nosec B105 — mock value for Jinja rendering, not a real secret
"s3_backup_bucket": "test-bucket",
"s3_endpoint": "https://s3.test",
"s3_access_key": "test-key",
"s3_secret_key": "test-secret", # nosec B105 — mock value for Jinja rendering, not a real secret
}
EXPR_PATTERN = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
def _default_ansible_dirs() -> list[Path]:
"""Return the default directories to scan for Ansible files."""
return [
REPO_ROOT / "ansible" / "playbooks",
REPO_ROOT / "ansible" / "roles",
]
def _find_yaml_files(path: Path) -> list[Path]:
"""Find Ansible YAML files (tasks, playbooks, handlers) in a path."""
return AnsibleFileFinder.find_yaml_files(path, skip_molecule=True)
def _extract_expressions(content: str) -> list[str]:
"""Extract Jinja expressions from file content."""
expressions = []
for match in EXPR_PATTERN.finditer(content):
raw = match.group(1)
if "\n" in raw:
continue
expr = raw.strip()
if not expr or expr.startswith("%") or len(expr) <= 1:
continue
if expr.startswith(".") or "println" in expr:
continue
if ".State." in expr or ".NetworkSettings." in expr:
continue
if expr.count("(") != expr.count(")"):
continue
if expr.count("{") != expr.count("}"):
continue
if expr.count("[") != expr.count("]"):
continue
expressions.append(expr)
return expressions
def _render_expression(expr: str) -> tuple[bool, str]:
"""Try to render a Jinja expression. Returns (success, error_msg)."""
try:
env = Environment(autoescape=False, keep_trailing_newline=True) # nosec B701 — Ansible Jinja, not web-facing # noqa: S701
def _strftime(string_format: str, second: float | None = None, utc: bool = False) -> str:
if isinstance(string_format, (int, float)) and isinstance(second, str) and "%" in second:
raise ValueError( # noqa: TRY301
"Invalid value for epoch value — strftime filter arguments "
"are reversed. The format string must be the piped value: "
"'%format%' | strftime(epoch), not epoch | strftime('%format%')"
)
return str(string_format)
env.filters["strftime"] = _strftime
env.filters["b64decode"] = lambda x: x
env.filters["b64encode"] = lambda x: x
env.filters["regex_replace"] = lambda x, pattern, replacement="": x
env.filters["int"] = lambda x, default=0: (
int(x) if isinstance(x, (int, float, str)) and str(x).lstrip("-").isdigit() else default
)
env.filters["bool"] = bool
env.filters["basename"] = lambda x: str(x).rsplit("/", 1)[-1]
env.filters["dirname"] = lambda x: str(x).rsplit("/", 1)[0] if "/" in str(x) else "."
env.filters["combine"] = lambda *args, **kwargs: args[0]
env.filters["from_json"] = lambda x: x
env.filters["to_json"] = lambda x: x
env.filters["ternary"] = lambda x, true_val, false_val=None: true_val if x else false_val
env.filters["dict2items"] = lambda x: [
{"key": k, "value": v} for k, v in (x.items() if isinstance(x, dict) else [])
]
env.filters["map"] = lambda x, attribute=None: x
env.filters["default"] = lambda x, default_value="", boolean=False: x if x else default_value
env.filters["from_yaml"] = lambda x: x
env.filters["difference"] = lambda x, y: x
env.filters["join"] = lambda x, sep="": sep.join(str(i) for i in (x if isinstance(x, list) else [x]))
env.filters["list"] = lambda x: list(x) if isinstance(x, (list, tuple)) else [x]
env.filters["length"] = lambda x: len(x) if hasattr(x, "__len__") else 0
env.filters["items"] = lambda x: list(x.items()) if isinstance(x, dict) else []
env.filters["first"] = lambda x: x[0] if isinstance(x, (list, str)) and x else x
env.filters["last"] = lambda x: x[-1] if isinstance(x, (list, str)) and x else x
env.filters["upper"] = lambda x: str(x).upper()
env.filters["lower"] = lambda x: str(x).lower()
env.filters["replace"] = lambda x, old, new: str(x).replace(old, new)
env.filters["split"] = lambda x, sep=None: str(x).split(sep) if sep else str(x).split()
env.filters["trim"] = lambda x: str(x).strip()
env.filters["sort"] = lambda x: sorted(x) if isinstance(x, list) else x
env.filters["unique"] = lambda x: list(set(x)) if isinstance(x, list) else x
env.filters["count"] = lambda x: len(x) if hasattr(x, "__len__") else 0
env.filters["float"] = lambda x, default=0.0: (
float(x) if isinstance(x, (int, float, str)) and str(x).replace(".", "").lstrip("-").isdigit() else default
)
env.filters["string"] = str
env.filters["indent"] = lambda x, width=4: str(x)
env.filters["to_nice_json"] = str
env.filters["to_nice_yaml"] = str
env.filters["from_yaml_all"] = lambda x: x
env.filters["groupby"] = lambda x: x
env.filters["dictsort"] = lambda x: list(x.items()) if isinstance(x, dict) else []
env.filters["max"] = lambda x: max(x) if isinstance(x, list) and x else x
env.filters["min"] = lambda x: min(x) if isinstance(x, list) and x else x
env.filters["reverse"] = lambda x: list(reversed(x)) if isinstance(x, list) else x
env.filters["flatten"] = lambda x: x
env.filters["product"] = lambda x: x
env.filters["zip"] = lambda x: x
env.filters["subelements"] = lambda x: x
env.filters["json_query"] = lambda x: x
env.filters["type_debug"] = lambda x: type(x).__name__
env.globals["lookup"] = lambda *args, **kwargs: ""
env.globals["query"] = lambda *args, **kwargs: []
template = env.from_string("{{ " + expr + " }}")
result = template.render(**MOCK_CONTEXT)
except TemplateSyntaxError as e:
return False, f"Syntax error: {e.message}"
except UndefinedError as e:
return True, f"Skipped (undefined: {e})"
except Exception as e:
error_msg = str(e)
if "Invalid value for epoch" in error_msg:
return False, f"strftime filter argument error: {error_msg}"
return True, f"Skipped ({type(e).__name__}: {error_msg})"
else:
return True, result
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
"""Check all Jinja expressions in a file. Returns list of violations."""
violations = []
content = filepath.read_text()
expressions = _extract_expressions(content)
for expr in expressions:
success, msg = _render_expression(expr)
if not success:
display_path = ViolationReporter.format_violation(filepath, repo_root, None, "")
display_path = display_path.removesuffix("")
violations.append(f"{display_path}: `{{{{ {expr} }}}}` — {msg}")
return violations
def check_jinja_expr(path: Path | None, ansible_dirs: list[Path] | None = None) -> list[str]:
"""Validate Jinja2 expressions in Ansible files.
Args:
path: Specific file or directory to check. If None, *ansible_dirs*
is used.
ansible_dirs: Directories to scan when *path* is None.
Returns:
List of violation messages (empty if all renderable expressions pass).
"""
if path:
files = _find_yaml_files(path)
else:
files: list[Path] = []
for d in ansible_dirs or _default_ansible_dirs():
files.extend(_find_yaml_files(d))
all_violations: list[str] = []
for f in files:
all_violations.extend(_check_file(f, REPO_ROOT))
return all_violations
+128
View File
@@ -0,0 +1,128 @@
"""Check Ansible tasks for missing no_log on secret-handling tasks.
Extracted from :mod:`devx.tools.check_ansible_no_log` as part of the
Ansible check tool consolidation. The old module remains as a thin
wrapper for backward compatibility.
"""
from __future__ import annotations
import re
from pathlib import Path
from devx.tools.ansible_checks._shared import AnsibleFileFinder, AnsibleYAMLParser
# Patterns that indicate a task is handling secrets.
SECRET_PATTERNS = [
re.compile(r"\{\{[^}]*_secrets\.", re.IGNORECASE),
re.compile(r"\{\{[^}]*password", re.IGNORECASE),
re.compile(r"\{\{[^}]*_secret\b", re.IGNORECASE),
re.compile(r"\{\{[^}]*api_key", re.IGNORECASE),
re.compile(r"\{\{[^}]*(?:vault_token|auth_token|access_token|bot_token)", re.IGNORECASE),
]
TASK_VALUE_KEYS = {
"shell",
"command",
"ansible.builtin.shell",
"ansible.builtin.command",
"ansible.builtin.template",
"ansible.builtin.copy",
"ansible.builtin.debug",
"template",
"copy",
"debug",
"cmd",
"msg",
"content",
}
NON_VALUE_KEYS = {
"name",
"when",
"loop",
"loop_control",
"changed_when",
"failed_when",
"no_log",
"register",
"tags",
"vars",
"become",
"become_user",
"delegate_to",
"run_once",
"environment",
"with_items",
"with_dict",
"with_list",
}
def _contains_secret(value: object) -> bool:
"""Recursively check if a value contains secret-like variable references."""
if isinstance(value, str):
return any(p.search(value) for p in SECRET_PATTERNS)
if isinstance(value, dict):
return any(_contains_secret(v) for v in value.values())
if isinstance(value, list):
return any(_contains_secret(item) for item in value)
return False
def _has_no_log(task: dict) -> bool:
"""Check if a task has no_log set to a non-False value."""
no_log = task.get("no_log", False)
return no_log is not False and no_log is not None
def _check_task(task: dict, file_path: Path, task_num: int) -> list[str]:
"""Check a single task for missing no_log on secret values."""
violations: list[str] = []
if _has_no_log(task):
return violations
has_secrets = False
for key, value in task.items():
if key in NON_VALUE_KEYS:
continue
if _contains_secret(value):
has_secrets = True
break
if has_secrets:
task_name = task.get("name", "<unnamed>")
violations.append(
f"{file_path}:{task_num}: Task '{task_name}' references secrets "
f"but has no no_log. Add `no_log: true` or "
f'`no_log: "{{{{ not (debug_mode | default(false) | bool) }}}}"` '
f"to prevent credential leakage in Ansible output."
)
return violations
def check_no_log(path: Path, ansible_dirs: list[Path] | None = None) -> list[str]:
"""Check all Ansible task files for missing no_log on secret-handling tasks.
Args:
path: The base directory to scan (or a specific file).
ansible_dirs: Unused kept for API symmetry with other checks.
The no_log checker scans *path* directly.
Returns:
List of violation messages (empty if all OK).
"""
all_violations: list[str] = []
task_files = AnsibleFileFinder.find_task_and_playbook_files(path)
for task_file in task_files:
try:
content = task_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
docs = AnsibleYAMLParser.parse_file(content)
for doc in docs:
for task, task_num in AnsibleYAMLParser.iter_tasks(doc):
all_violations.extend(_check_task(task, task_file, task_num))
return all_violations
# Backward-compat alias for the old public function name.
check_directory = check_no_log
@@ -0,0 +1,100 @@
"""Check Ansible tasks for ``state: absent`` on database data directories.
Extracted from :mod:`devx.tools.check_ansible_no_state_absent_on_db` as
part of the Ansible check tool consolidation. The old module remains as
a thin wrapper for backward compatibility.
"""
from __future__ import annotations
import re
from pathlib import Path
from devx.tools.ansible_checks._shared import AnsibleFileFinder, ViolationReporter
REPO_ROOT = Path.cwd()
DB_PATH_PATTERNS = (
re.compile(r"postgres/zitadel-db", re.IGNORECASE),
re.compile(r"postgres/\w+-db", re.IGNORECASE),
re.compile(r"/var/lib/postgresql/data", re.IGNORECASE),
re.compile(r"/var/lib/postgresql/data/\w+-db", re.IGNORECASE),
)
DESTRUCTIVE_PATTERNS = (
re.compile(r"state:\s*absent", re.IGNORECASE),
re.compile(r"rm\s+-rf.*\bdb\b", re.IGNORECASE),
)
ALLOWED_CONTEXT_KEYWORDS = (
"upgrade-postgres",
"PG_VERSION",
"pg_version",
)
ALLOW_MARKER = "lint:allow-state-absent"
def _find_task_files(base: Path) -> list[Path]:
"""Find all YAML task files under a base directory, skipping molecule."""
return AnsibleFileFinder.find_task_files(base, skip_molecule=True)
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
"""Check a YAML file for state: absent on DB data directory paths."""
try:
content = filepath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return []
if not any(p.search(content) for p in DB_PATH_PATTERNS):
return []
display_path = ViolationReporter.format_violation(filepath, repo_root, None, "")
display_path = display_path.removesuffix("")
violations: list[str] = []
lines = content.splitlines()
for i, line in enumerate(lines):
for db_pattern in DB_PATH_PATTERNS:
if not db_pattern.search(line):
continue
context_start = max(0, i - 5)
context_end = min(len(lines), i + 6)
context = "\n".join(lines[context_start:context_end])
if any(kw in context for kw in ALLOWED_CONTEXT_KEYWORDS):
continue
if ALLOW_MARKER in context:
continue
for dp in DESTRUCTIVE_PATTERNS:
if dp.search(context):
violations.append(
f"{display_path}:{i + 1} — destructive operation "
f"({dp.pattern!r}) near DB data directory path "
f"({db_pattern.pattern!r}). "
f"Database directories must never be wiped automatically (ADR-0028). "
f"If this is legitimate (e.g. PG upgrade), add "
f"#{ALLOW_MARKER} to the task."
)
break
return violations
def check_no_state_absent_on_db(path: Path | None, ansible_dirs: list[Path] | None = None) -> list[str]:
"""Check that no Ansible task uses state: absent on a DB data directory.
Args:
path: Specific file or directory to check. If None, *ansible_dirs*
is used.
ansible_dirs: Directories to scan when *path* is None.
Returns:
List of violation messages (empty if clean).
"""
if path:
files = _find_task_files(path)
else:
files: list[Path] = []
for d in ansible_dirs or []:
files.extend(_find_task_files(d))
all_violations: list[str] = []
for f in files:
all_violations.extend(_check_file(f, REPO_ROOT))
return all_violations
+226
View File
@@ -0,0 +1,226 @@
"""Check Ansible tasks for dangerous patterns that mask failures.
Extracted from :mod:`devx.tools.check_ansible_patterns` as part of the
Ansible check tool consolidation. The old module remains as a thin
wrapper for backward compatibility.
"""
from __future__ import annotations
import re
from pathlib import Path
from devx.tools.ansible_checks._shared import AnsibleFileFinder, AnsibleYAMLParser
REPO_ROOT = Path.cwd()
# Comment marker to explicitly allow a pattern on a specific task
ALLOW_MARKER = "lint:allow-failure-masking"
# Patterns that mask failures when used in shell/command tasks
OR_TRUE_PATTERN = re.compile(r"\|\|\s*true\b", re.IGNORECASE)
REDIRECT_DEVNULL_PATTERN = re.compile(r"2>/dev/null")
# Module keys that accept shell/command strings
SHELL_MODULE_KEYS = frozenset(
{
"shell",
"command",
"ansible.builtin.shell",
"ansible.builtin.command",
"cmd",
"ansible.builtin.raw",
"raw",
}
)
# Task keys whose values might contain shell commands
COMMAND_VALUE_KEYS = frozenset(
{
"shell",
"command",
"ansible.builtin.shell",
"ansible.builtin.command",
"cmd",
"raw",
"ansible.builtin.raw",
}
)
LEGITIMATE_COMMAND_PREFIXES = (
"docker rm",
"docker stop",
"docker rmi",
"docker network rm",
"docker volume rm",
"pkill",
"kill",
"journalctl --vacuum",
"apt-get clean",
"apt-get autoremove",
"docker image prune",
"docker container prune",
"docker volume prune",
"docker builder prune",
"find / -name",
"chmod",
"rm -f",
"docker network connect",
"curl.*api/v2/admin/tsdb/snapshot",
)
LEGITIMATE_TASK_NAME_KEYWORDS = (
"remove",
"cleanup",
"clean up",
"prune",
"purge",
"disconnect",
"stop",
"kill",
"strip suid",
"suid",
"vacuum",
"ensure.*absent",
"may not exist",
"if exists",
"optional",
"best effort",
"no-op",
"noop",
"idempotent",
"sync",
)
CRITICAL_TASK_KEYWORDS = (
"password",
"secret",
"provision",
"oidc",
)
LEGITIMATE_FAILED_WHEN_KEYWORDS = (
"stop",
"start",
"check",
"wait",
"migrate",
"restart",
"rebuild",
"restore",
"remove",
"cleanup",
"sync",
"download",
"extract",
"verify",
)
def _is_legitimate_or_true(command_str: str, task_name: str) -> bool:
"""Check if a || true in a command is in a legitimate context."""
name_lower = task_name.lower()
if any(re.search(kw, name_lower) for kw in LEGITIMATE_TASK_NAME_KEYWORDS):
return True
cmd_lower = command_str.lower()
return any(re.search(prefix, cmd_lower) for prefix in LEGITIMATE_COMMAND_PREFIXES)
def _is_legitimate_devnull(command_str: str, task_name: str) -> bool:
"""Check if a 2>/dev/null in a command is in a legitimate context."""
return _is_legitimate_or_true(command_str, task_name)
def _check_task(task: dict, filepath: Path, task_num: int, repo_root: Path) -> list[str]:
"""Check a single task for dangerous failure-masking patterns."""
violations: list[str] = []
try:
display_path = filepath.relative_to(repo_root)
except ValueError:
display_path = filepath
task_name = task.get("name", "<unnamed>")
if ALLOW_MARKER in task_name:
return violations
for key in COMMAND_VALUE_KEYS:
value = task.get(key)
if value is None:
continue
value_str = str(value)
if OR_TRUE_PATTERN.search(value_str) and not _is_legitimate_or_true(value_str, task_name):
violations.append(
f"{display_path}:{task_num} — task '{task_name}' uses "
f"'|| true' in {key} which may mask real failures. "
f"If this is a cleanup/idempotency operation, rename the "
f"task to include 'remove'/'cleanup'/'prune' or add "
f"#{ALLOW_MARKER} to the task."
)
failed_when = task.get("failed_when")
if failed_when is False:
name_lower = task_name.lower()
is_legitimate = any(kw in name_lower for kw in LEGITIMATE_FAILED_WHEN_KEYWORDS)
if not is_legitimate:
for kw in CRITICAL_TASK_KEYWORDS:
if kw in name_lower:
violations.append(
f"{display_path}:{task_num} — critical task '{task_name}' "
f"has failed_when: false, which masks failures on "
f"a {kw}-related operation. Remove failed_when: false "
f"or add #{ALLOW_MARKER} if masking is intentional."
)
break
return violations
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
"""Check a YAML file for dangerous failure-masking patterns."""
try:
content = filepath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return []
if not (
OR_TRUE_PATTERN.search(content) or "failed_when: false" in content or REDIRECT_DEVNULL_PATTERN.search(content)
):
return []
has_allow_marker = ALLOW_MARKER in content
docs = AnsibleYAMLParser.parse_file(content)
violations: list[str] = []
for doc in docs:
for task, task_num in AnsibleYAMLParser.iter_tasks(doc):
violations.extend(_check_task(task, filepath, task_num, repo_root))
if has_allow_marker:
violations = []
return violations
def _check_tasks(doc: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check top-level tasks and nested task sections in a playbook doc."""
for task, task_num in AnsibleYAMLParser._iter_play_sections(doc):
errors.extend(_check_task(task, filepath, task_num, repo_root))
def _find_task_files(base: Path) -> list[Path]:
"""Find all YAML task files under a base directory, skipping molecule."""
return AnsibleFileFinder.find_task_files(base, skip_molecule=True)
def check_patterns(path: Path | None, ansible_dirs: list[Path] | None = None) -> list[str]:
"""Check Ansible tasks for dangerous failure-masking patterns.
Args:
path: Specific file or directory to check. If None, *ansible_dirs*
is used.
ansible_dirs: Directories to scan when *path* is None.
Returns:
List of violation messages (empty if clean).
"""
if path:
files = _find_task_files(path)
else:
files: list[Path] = []
for d in ansible_dirs or []:
files.extend(_find_task_files(d))
all_violations: list[str] = []
for f in files:
all_violations.extend(_check_file(f, REPO_ROOT))
return all_violations
@@ -0,0 +1,134 @@
"""Check that Ansible ``set_fact`` tasks don't misuse ``| to_json``.
Extracted from :mod:`devx.tools.check_ansible_set_fact_to_json` as part
of the Ansible check tool consolidation. The old module remains as a
thin wrapper for backward compatibility.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from devx.tools.ansible_checks._shared import AnsibleFileFinder, ViolationReporter
REPO_ROOT = Path.cwd()
TO_JSON_FILTERS = ("| to_json", "| to_nice_json", "|to_json", "|to_nice_json")
def _find_task_files(base: Path) -> list[Path]:
"""Find all YAML task files under a base directory."""
return AnsibleFileFinder.find_task_files(base, skip_molecule=False)
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
"""Check a single YAML file for set_fact + to_json misuse."""
errors: list[str] = []
try:
content = filepath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return errors
try:
docs = list(yaml.safe_load_all(content))
except yaml.YAMLError as exc:
return [f"{filepath}: cannot parse YAML: {exc}"]
for doc in docs:
if isinstance(doc, list):
for item in doc:
if isinstance(item, dict):
if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers", "roles")):
_check_tasks(item, filepath, errors, repo_root)
else:
_check_task(item, filepath, errors, repo_root)
block = item.get("block")
if isinstance(block, list):
_check_task_list(block, filepath, errors, repo_root)
elif isinstance(doc, dict):
_check_tasks(doc, filepath, errors, repo_root)
return errors
def _check_tasks(doc: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check top-level tasks and nested task sections in a playbook doc."""
tasks = doc.get("tasks")
if isinstance(tasks, list):
_check_task_list(tasks, filepath, errors, repo_root)
for role_key in ("pre_tasks", "post_tasks", "handlers"):
section = doc.get(role_key)
if isinstance(section, list):
_check_task_list(section, filepath, errors, repo_root)
roles = doc.get("roles")
if isinstance(roles, list):
for role_entry in roles:
if isinstance(role_entry, dict):
role_tasks = role_entry.get("tasks")
if isinstance(role_tasks, list):
_check_task_list(role_tasks, filepath, errors, repo_root)
def _check_task_list(tasks: list, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check a list of task definitions for set_fact + to_json."""
for task in tasks:
if not isinstance(task, dict):
continue
_check_task(task, filepath, errors, repo_root)
block = task.get("block")
if isinstance(block, list):
_check_task_list(block, filepath, errors, repo_root)
def _check_task(task: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check a single task for set_fact + to_json misuse."""
has_set_fact = False
for key in task:
if key in {"set_fact", "ansible.builtin.set_fact"}:
has_set_fact = True
break
if not has_set_fact:
return
set_fact_body = task.get("set_fact") or task.get("ansible.builtin.set_fact")
if not isinstance(set_fact_body, dict):
return
task_name = task.get("name", "(unnamed)")
for fact_name, fact_value in set_fact_body.items():
if fact_name in ("cacheable",):
continue
value_str = str(fact_value)
for filter_pattern in TO_JSON_FILTERS:
if filter_pattern in value_str:
display_path = ViolationReporter.format_violation(filepath, repo_root, None, "")
display_path = display_path.removesuffix("")
errors.append(
f"{display_path}: task '{task_name}' "
f"sets fact '{fact_name}' with '{filter_pattern.strip()}' "
f"— this converts native Python types to JSON strings. "
f"Remove the filter to preserve the native type, or use "
f"'| from_json' in the consuming task if the string "
f"representation is intentional."
)
break
def check_set_fact_to_json(path: Path | None, ansible_dirs: list[Path] | None = None) -> list[str]:
"""Check that set_fact tasks don't misuse to_json.
Args:
path: Specific file or directory to check. If None, *ansible_dirs*
is used.
ansible_dirs: Directories to scan when *path* is None.
Returns:
List of error messages (empty if all OK).
"""
if path:
files = _find_task_files(path)
else:
files: list[Path] = []
for d in ansible_dirs or []:
files.extend(_find_task_files(d))
all_errors: list[str] = []
for f in files:
all_errors.extend(_check_file(f, REPO_ROOT))
return all_errors
+71
View File
@@ -0,0 +1,71 @@
"""Check Ansible tasks for missing no_log on secret-handling tasks.
Thin wrapper around :mod:`devx.tools.ansible_checks.no_log` for
backward compatibility. The check logic lives in the subpackage; this
module preserves the CLI entry point and re-exports the internal
helpers so existing tests and imports continue to work.
Usage::
python -m devx.tools.check_ansible_no_log
python -m devx.tools.check_ansible_no_log --path ansible/roles/my_role
python -m devx.tools.check_ansible_no_log --ansible-dir ansible/roles
Exit code 0 if all secret-handling tasks have no_log, 1 otherwise.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from devx.tools.ansible_checks.no_log import (
NON_VALUE_KEYS, # noqa: F401
SECRET_PATTERNS, # noqa: F401 — re-exported for backward compat
TASK_VALUE_KEYS, # noqa: F401
_check_task, # noqa: F401
_contains_secret, # noqa: F401
_has_no_log, # noqa: F401
check_directory, # noqa: F401
check_no_log, # noqa: F401
)
REPO_ROOT = Path.cwd()
DEFAULT_ANSIBLE_DIR = REPO_ROOT / "ansible"
@click.command()
@click.option(
"--path",
type=click.Path(exists=True, path_type=Path),
help="Check a specific file or directory (default: ansible/).",
)
@click.option(
"--ansible-dir",
type=click.Path(exists=True, path_type=Path),
default=None,
help="Override the default ansible directory (default: ansible/).",
)
def main(path: Path | None, ansible_dir: Path | None) -> None:
"""Check that Ansible tasks handling secrets have no_log set."""
target = path or ansible_dir or DEFAULT_ANSIBLE_DIR
if not target.is_dir():
click.echo(f"Error: {target} is not a directory", err=True)
sys.exit(2)
violations = check_no_log(target)
if violations:
click.echo(f"Found {len(violations)} task(s) handling secrets without no_log:\n")
for v in violations:
click.echo(f" {v}")
click.echo(f"\nTotal: {len(violations)} violation(s).")
sys.exit(1)
click.echo(f"[check-ansible-no-log] All secret-handling tasks have no_log. ({target})")
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,72 @@
"""Check Ansible tasks for ``state: absent`` on database data directories.
Thin wrapper around
:mod:`devx.tools.ansible_checks.no_state_absent_on_db` for backward
compatibility. The check logic lives in the subpackage; this module
preserves the CLI entry point and re-exports the internal helpers so
existing tests and imports continue to work.
Usage::
python -m devx.tools.check_ansible_no_state_absent_on_db
python -m devx.tools.check_ansible_no_state_absent_on_db --path ansible/roles/zitadel/tasks/main.yml
Exit code 0 if no violations found, 1 otherwise.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from devx.tools.ansible_checks.no_state_absent_on_db import (
ALLOW_MARKER, # noqa: F401 — re-exported for backward compat
ALLOWED_CONTEXT_KEYWORDS, # noqa: F401
DB_PATH_PATTERNS, # noqa: F401
DESTRUCTIVE_PATTERNS, # noqa: F401
REPO_ROOT,
_check_file, # noqa: F401
_find_task_files, # noqa: F401
check_no_state_absent_on_db, # noqa: F401
)
DEFAULT_ANSIBLE_DIRS: list[Path] = [
REPO_ROOT / "ansible" / "playbooks",
REPO_ROOT / "ansible" / "roles",
]
@click.command()
@click.option(
"--path",
type=click.Path(exists=True, path_type=Path),
help="Check a specific file or directory (default: ansible/playbooks + ansible/roles).",
)
@click.option(
"--ansible-dir",
"ansible_dirs",
type=click.Path(exists=True, path_type=Path),
multiple=True,
default=None,
help="Override the default ansible directories (can be repeated). Defaults to ansible/playbooks and ansible/roles.",
)
def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None:
"""Check that no Ansible task uses state: absent on a DB data directory."""
dirs = list(ansible_dirs) if ansible_dirs else DEFAULT_ANSIBLE_DIRS
all_violations = check_no_state_absent_on_db(path) if path else check_no_state_absent_on_db(None, dirs)
if all_violations:
click.echo("[check-ansible-no-state-absent-on-db] FAIL: destructive operations on DB paths:")
for v in all_violations:
click.echo(f" - {v}")
click.echo(f"\nTotal: {len(all_violations)} violation(s).")
click.echo("Database data directories must never be wiped automatically (ADR-0028).")
sys.exit(1)
else:
click.echo("[check-ansible-no-state-absent-on-db] OK: no destructive operations on DB paths.")
if __name__ == "__main__": # pragma: no cover
main()
+79
View File
@@ -0,0 +1,79 @@
"""Check Ansible tasks for dangerous patterns that mask failures.
Thin wrapper around :mod:`devx.tools.ansible_checks.patterns` for
backward compatibility. The check logic lives in the subpackage; this
module preserves the CLI entry point and re-exports the internal
helpers so existing tests and imports continue to work.
Usage::
python -m devx.tools.check_ansible_patterns
python -m devx.tools.check_ansible_patterns --path ansible/roles/app_container/tasks/main.yml
Exit code 0 if no violations found, 1 otherwise.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from devx.tools.ansible_checks.patterns import (
ALLOW_MARKER, # noqa: F401 — re-exported for backward compat
COMMAND_VALUE_KEYS, # noqa: F401
CRITICAL_TASK_KEYWORDS, # noqa: F401
LEGITIMATE_COMMAND_PREFIXES, # noqa: F401
LEGITIMATE_FAILED_WHEN_KEYWORDS, # noqa: F401
LEGITIMATE_TASK_NAME_KEYWORDS, # noqa: F401
OR_TRUE_PATTERN, # noqa: F401
REDIRECT_DEVNULL_PATTERN, # noqa: F401
REPO_ROOT,
SHELL_MODULE_KEYS, # noqa: F401
_check_file, # noqa: F401
_check_task, # noqa: F401
_check_tasks, # noqa: F401
_find_task_files, # noqa: F401
_is_legitimate_devnull, # noqa: F401
_is_legitimate_or_true, # noqa: F401
check_patterns, # noqa: F401
)
DEFAULT_ANSIBLE_DIRS: list[Path] = [
REPO_ROOT / "ansible" / "playbooks",
REPO_ROOT / "ansible" / "roles",
]
@click.command()
@click.option(
"--path",
type=click.Path(exists=True, path_type=Path),
help="Check a specific file or directory (default: ansible/playbooks + ansible/roles).",
)
@click.option(
"--ansible-dir",
"ansible_dirs",
type=click.Path(exists=True, path_type=Path),
multiple=True,
default=None,
help="Override the default ansible directories (can be repeated). Defaults to ansible/playbooks and ansible/roles.",
)
def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None:
"""Check Ansible tasks for dangerous failure-masking patterns."""
dirs = list(ansible_dirs) if ansible_dirs else DEFAULT_ANSIBLE_DIRS
all_violations = check_patterns(path) if path else check_patterns(None, dirs)
if all_violations:
click.echo("[check-ansible-patterns] FAIL: dangerous failure-masking patterns found:")
for v in all_violations:
click.echo(f" - {v}")
click.echo(f"\nTotal: {len(all_violations)} violation(s).")
sys.exit(1)
else:
click.echo("[check-ansible-patterns] OK: no dangerous failure-masking patterns.")
if __name__ == "__main__": # pragma: no cover
main()
+16 -143
View File
@@ -1,19 +1,9 @@
"""Check that Ansible ``set_fact`` tasks don't misuse ``| to_json``.
This prevents the class of bug where ``set_fact`` tasks use
``{{ targets | to_json }}`` to store Python lists, but ``to_json``
converts native types to JSON strings. Ansible then stored the result
as a string, so iterating over the fact yielded individual characters
instead of list items, causing ``object of type 'str' has no attribute
'ip'`` errors.
The check scans all Ansible task files (playbooks and role tasks) for
``set_fact`` tasks where any value uses ``| to_json`` or ``| to_nice_json``
and flags them as potential bugs.
``| to_json`` is legitimate in Jinja2 templates (e.g., rendering JSON
config files) but almost never correct in ``set_fact`` the fact should
store the native Python type so downstream tasks can iterate/index it.
Thin wrapper around :mod:`devx.tools.ansible_checks.set_fact_to_json`
for backward compatibility. The check logic lives in the subpackage;
this module preserves the CLI entry point and re-exports the internal
helpers so existing tests and imports continue to work.
Usage::
@@ -29,130 +19,23 @@ import sys
from pathlib import Path
import click
import yaml
REPO_ROOT = Path.cwd()
from devx.tools.ansible_checks.set_fact_to_json import (
REPO_ROOT,
TO_JSON_FILTERS, # noqa: F401 — re-exported for backward compat
_check_file, # noqa: F401
_check_task, # noqa: F401
_check_task_list, # noqa: F401
_check_tasks, # noqa: F401
_find_task_files, # noqa: F401
check_set_fact_to_json, # noqa: F401
)
DEFAULT_ANSIBLE_DIRS: list[Path] = [
REPO_ROOT / "ansible" / "playbooks",
REPO_ROOT / "ansible" / "roles",
]
TO_JSON_FILTERS = ("| to_json", "| to_nice_json", "|to_json", "|to_nice_json")
def _find_task_files(base: Path) -> list[Path]:
"""Find all YAML task files under a base directory."""
if base.is_file() and base.suffix in (".yml", ".yaml"):
return [base]
if not base.is_dir():
return []
return sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml"))
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
"""Check a single YAML file for set_fact + to_json misuse.
Returns a list of error messages (empty if all OK).
"""
errors: list[str] = []
content = filepath.read_text(encoding="utf-8")
# Multi-document YAML (--- separators) is common in playbooks
try:
docs = list(yaml.safe_load_all(content))
except yaml.YAMLError as exc:
return [f"{filepath}: cannot parse YAML: {exc}"]
for doc in docs:
if isinstance(doc, list):
# Could be a playbook (list of plays) or a role tasks file (list of tasks)
for item in doc:
if isinstance(item, dict):
if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers", "roles")):
# It's a play
_check_tasks(item, filepath, errors, repo_root)
else:
# It's a bare task (role tasks file)
_check_task(item, filepath, errors, repo_root)
block = item.get("block")
if isinstance(block, list):
_check_task_list(block, filepath, errors, repo_root)
elif isinstance(doc, dict):
# Role tasks file or single play — _check_tasks handles all task sections
_check_tasks(doc, filepath, errors, repo_root)
return errors
def _check_tasks(doc: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check top-level tasks and nested task sections in a playbook doc."""
tasks = doc.get("tasks")
if isinstance(tasks, list):
_check_task_list(tasks, filepath, errors, repo_root)
for role_key in ("pre_tasks", "post_tasks", "handlers"):
section = doc.get(role_key)
if isinstance(section, list):
_check_task_list(section, filepath, errors, repo_root)
# Check tasks in roles imported via `roles:` key
roles = doc.get("roles")
if isinstance(roles, list):
for role_entry in roles:
if isinstance(role_entry, dict):
role_tasks = role_entry.get("tasks")
if isinstance(role_tasks, list):
_check_task_list(role_tasks, filepath, errors, repo_root)
def _check_task_list(tasks: list, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check a list of task definitions for set_fact + to_json."""
for task in tasks:
if not isinstance(task, dict):
continue
_check_task(task, filepath, errors, repo_root)
# Check nested block tasks
block = task.get("block")
if isinstance(block, list):
_check_task_list(block, filepath, errors, repo_root)
def _check_task(task: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
"""Check a single task for set_fact + to_json misuse."""
# Detect set_fact — could be a module name key or ansible.builtin.set_fact
has_set_fact = False
for key in task:
if key in {"set_fact", "ansible.builtin.set_fact"}:
has_set_fact = True
break
if not has_set_fact:
return
set_fact_body = task.get("set_fact") or task.get("ansible.builtin.set_fact")
if not isinstance(set_fact_body, dict):
return
task_name = task.get("name", "(unnamed)")
for fact_name, fact_value in set_fact_body.items():
if fact_name in ("cacheable",):
continue
value_str = str(fact_value)
for filter_pattern in TO_JSON_FILTERS:
if filter_pattern in value_str:
try:
display_path = filepath.relative_to(repo_root)
except ValueError:
display_path = filepath
errors.append(
f"{display_path}: task '{task_name}' "
f"sets fact '{fact_name}' with '{filter_pattern.strip()}' "
f"— this converts native Python types to JSON strings. "
f"Remove the filter to preserve the native type, or use "
f"'| from_json' in the consuming task if the string "
f"representation is intentional."
)
break # One error per fact is enough
@click.command()
@click.option(
@@ -171,17 +54,7 @@ def _check_task(task: dict, filepath: Path, errors: list[str], repo_root: Path)
def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None:
"""Check that set_fact tasks don't misuse to_json."""
dirs = list(ansible_dirs) if ansible_dirs else DEFAULT_ANSIBLE_DIRS
if path:
files = _find_task_files(path)
else:
files: list[Path] = []
for d in dirs:
files.extend(_find_task_files(d))
all_errors: list[str] = []
for f in files:
errors = _check_file(f, REPO_ROOT)
all_errors.extend(errors)
all_errors = check_set_fact_to_json(path) if path else check_set_fact_to_json(None, dirs)
if all_errors:
click.echo("[check-ansible-set-fact-to-json] FAIL: set_fact with to_json found:")
+65
View File
@@ -0,0 +1,65 @@
"""Validate Jinja2 expressions in Ansible files by rendering them.
Thin wrapper around :mod:`devx.tools.ansible_checks.jinja_expr` for
backward compatibility. The check logic lives in the subpackage; this
module preserves the CLI entry point and re-exports the internal
helpers so existing tests and imports continue to work.
Usage::
python -m devx.tools.check_jinja_expr
python -m devx.tools.check_jinja_expr --path ansible/playbooks/deploy-observability.yml
Exit code 0 if all renderable expressions pass, 1 if any fail.
"""
from __future__ import annotations
import sys
from pathlib import Path
import click
from devx.tools.ansible_checks.jinja_expr import (
EXPR_PATTERN, # noqa: F401 — re-exported for backward compat
MOCK_CONTEXT, # noqa: F401
_check_file, # noqa: F401
_default_ansible_dirs, # noqa: F401
_extract_expressions, # noqa: F401
_find_yaml_files, # noqa: F401
_render_expression, # noqa: F401
check_jinja_expr, # noqa: F401
)
@click.command()
@click.option(
"--path",
type=click.Path(exists=True, path_type=Path),
help="Check a specific file or directory (default: ansible/playbooks + ansible/roles).",
)
@click.option(
"--ansible-dir",
"ansible_dirs",
type=click.Path(exists=True, path_type=Path),
multiple=True,
default=None,
help="Override the default ansible directories (can be repeated). Defaults to ansible/playbooks and ansible/roles.",
)
def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None:
"""Validate Jinja2 expressions in Ansible files."""
dirs = list(ansible_dirs) if ansible_dirs else _default_ansible_dirs()
all_violations = check_jinja_expr(path) if path else check_jinja_expr(None, dirs)
if all_violations:
click.echo("[check-jinja-expr] FAIL: invalid Jinja expressions found:")
for v in all_violations:
click.echo(f" - {v}")
click.echo("\nFix: test expressions with `ansible localhost -m debug -a 'msg={{ <expr> }}'`")
sys.exit(1)
else:
click.echo("[check-jinja-expr] OK: all Jinja expressions render correctly.")
if __name__ == "__main__": # pragma: no cover
main()
+69 -4
View File
@@ -22,18 +22,37 @@ Usage::
from __future__ import annotations
import logging
import os
import platform
import shutil
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
import click
from tenacity import (
Retrying,
before_sleep_log,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
TARGET_DIR = Path.home() / ".local" / "bin"
logger = logging.getLogger(__name__)
# Retry configuration for transient network failures during download.
# GitHub releases occasionally drops connections ("Remote end closed
# connection without response"). Retrying with backoff before falling
# through to the next fallback URL makes the build resilient to
# momentary network blips.
MAX_DOWNLOAD_RETRIES = 3
ACTIONLINT_VERSION = "1.7.12"
GIT_CLIFF_VERSION = "2.13.1"
@@ -64,12 +83,53 @@ def _ensure_target_dir() -> Path:
return TARGET_DIR
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest`` with a 60s timeout."""
def _download(url: str, dest: Path, *, _sleep=None) -> None:
"""Download a file from ``url`` to ``dest`` with retry and 60s timeout.
Retries up to ``MAX_DOWNLOAD_RETRIES`` times on transient network
errors (``URLError``, ``OSError`` from connection resets) using
exponential backoff. This handles momentary GitHub releases
connection drops that were causing CI image builds to fail.
The ``_sleep`` kwarg is for tests to avoid real sleeping; production
code should leave it as ``None`` (uses ``time.sleep``).
"""
retrying = Retrying(
stop=stop_after_attempt(MAX_DOWNLOAD_RETRIES),
wait=wait_exponential(multiplier=2, min=2, max=10),
retry=retry_if_exception_type((urllib.error.URLError, OSError, ConnectionError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
sleep=_sleep if _sleep is not None else time.sleep,
reraise=True,
)
retrying(_do_download, url, dest)
def _do_download(url: str, dest: Path) -> None:
"""Single download attempt — called by :func:`_download` retry wrapper."""
with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
shutil.copyfileobj(resp, f)
def _download_with_fallback(urls: list[str], binary_name: str) -> Path:
"""Try downloading a binary from a list of URLs, falling back on failure.
Returns the path to the installed binary. Raises if all URLs fail.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
errors: list[str] = []
for url in urls:
try:
_download(url, dest)
dest.chmod(0o755)
return dest
except Exception as exc: # noqa: BLE001
errors.append(f"{url}: {exc}")
click.echo(f" {binary_name}: retrying — {exc}")
raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
@@ -164,8 +224,13 @@ def install_tea() -> bool:
click.echo("tea: already installed")
return True
arch = _arch()
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
dest = _download_binary(url, "tea")
# dl.gitea.com is the primary CDN, but it can return 403 from some networks.
# Fall back to the gitea.com release downloads URL.
urls = [
f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
f"https://gitea.com/gitea/tea/releases/download/v{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
]
dest = _download_with_fallback(urls, "tea")
click.echo(f"tea: installed to {dest}")
return True
+12 -2
View File
@@ -15,6 +15,7 @@ from pathlib import Path
import click
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
@@ -56,13 +57,22 @@ def _install_pre_commit_hooks(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")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
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:
+3 -1
View File
@@ -64,9 +64,11 @@ def _install_in_image(
link.symlink_to(opt_venv)
# 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 "."
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()
try:
+4121 -3523
View File
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
"""Unit tests for devx.tools.ansible_checks._shared."""
from pathlib import Path
import pytest
from devx.tools.ansible_checks._shared import (
DEFAULT_ANSIBLE_DIRS,
AnsibleFileFinder,
AnsibleYAMLParser,
ViolationReporter,
)
class TestAnsibleFileFinder:
def test_find_task_files_single_yaml(self, tmp_path: Path) -> None:
f = tmp_path / "test.yml"
f.write_text("tasks: []")
assert AnsibleFileFinder.find_task_files(f) == [f]
def test_find_task_files_single_non_yaml(self, tmp_path: Path) -> None:
f = tmp_path / "test.txt"
f.write_text("hello")
assert AnsibleFileFinder.find_task_files(f) == []
def test_find_task_files_dir(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
(tmp_path / "b.yaml").write_text("tasks: []")
(tmp_path / "c.txt").write_text("hello")
result = AnsibleFileFinder.find_task_files(tmp_path)
assert len(result) == 2
assert all(f.suffix in (".yml", ".yaml") for f in result)
def test_find_task_files_skip_molecule(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
mol = tmp_path / "molecule" / "default"
mol.mkdir(parents=True)
(mol / "main.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=True)
assert len(result) == 1
assert "molecule" not in result[0].parts
def test_find_task_files_include_molecule(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
mol = tmp_path / "molecule" / "default"
mol.mkdir(parents=True)
(mol / "main.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_task_files(tmp_path, skip_molecule=False)
assert len(result) == 2
def test_find_task_files_nonexistent(self, tmp_path: Path) -> None:
assert AnsibleFileFinder.find_task_files(tmp_path / "nonexistent") == []
def test_find_yaml_files_single_file(self, tmp_path: Path) -> None:
f = tmp_path / "test.txt"
f.write_text("hello")
# find_yaml_files accepts any single file (no suffix check)
assert AnsibleFileFinder.find_yaml_files(f) == [f]
def test_find_yaml_files_dir(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.yaml").write_text("tasks: []")
result = AnsibleFileFinder.find_yaml_files(tmp_path)
assert len(result) == 2
def test_find_yaml_files_skip_molecule(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
mol = tmp_path / "molecule" / "default"
mol.mkdir(parents=True)
(mol / "main.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=True)
assert len(result) == 1
def test_find_yaml_files_include_molecule(self, tmp_path: Path) -> None:
(tmp_path / "a.yml").write_text("tasks: []")
mol = tmp_path / "molecule" / "default"
mol.mkdir(parents=True)
(mol / "main.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_yaml_files(tmp_path, skip_molecule=False)
assert len(result) == 2
def test_find_task_and_playbook_files(self, tmp_path: Path) -> None:
role = tmp_path / "roles" / "myrole"
(role / "tasks").mkdir(parents=True)
(role / "tasks" / "main.yml").write_text("tasks: []")
pb = tmp_path / "playbooks"
pb.mkdir()
(pb / "deploy.yml").write_text("tasks: []")
(tmp_path / "random.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path)
# Should find tasks/main.yml and playbooks/deploy.yml, not random.yml
names = [f.name for f in result]
assert "main.yml" in names
assert "deploy.yml" in names
assert "random.yml" not in names
def test_find_task_and_playbook_files_skip_molecule(self, tmp_path: Path) -> None:
role = tmp_path / "roles" / "myrole"
(role / "tasks").mkdir(parents=True)
(role / "tasks" / "main.yml").write_text("tasks: []")
mol = role / "molecule" / "default" / "tasks"
mol.mkdir(parents=True)
(mol / "main.yml").write_text("tasks: []")
result = AnsibleFileFinder.find_task_and_playbook_files(tmp_path, skip_molecule=True)
assert len(result) == 1
assert "molecule" not in result[0].parts
class TestAnsibleYAMLParser:
def test_parse_file_valid(self) -> None:
content = "---\n- name: test\n shell: echo hi\n"
docs = AnsibleYAMLParser.parse_file(content)
assert len(docs) == 1
assert isinstance(docs[0], list)
def test_parse_file_multi_doc(self) -> None:
content = "---\n- a\n---\n- b\n"
docs = AnsibleYAMLParser.parse_file(content)
assert len(docs) == 2
def test_parse_file_empty_docs_filtered(self) -> None:
content = "---\n- a\n---\n\n"
docs = AnsibleYAMLParser.parse_file(content)
assert len(docs) == 1
def test_parse_file_yaml_error(self) -> None:
content = "{{ invalid: ["
docs = AnsibleYAMLParser.parse_file(content)
assert docs == []
def test_iter_tasks_bare_list(self) -> None:
doc = [{"name": "task1", "shell": "echo hi"}, {"name": "task2", "shell": "echo bye"}]
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 2
assert tasks[0][0]["name"] == "task1"
assert tasks[0][1] == 1
assert tasks[1][1] == 2
def test_iter_tasks_play_dict(self) -> None:
doc = {"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]}
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 1
assert tasks[0][0]["name"] == "task1"
def test_iter_tasks_play_with_pre_post_handlers(self) -> None:
doc = {
"hosts": "all",
"pre_tasks": [{"name": "pre", "shell": "echo pre"}],
"tasks": [{"name": "main", "shell": "echo main"}],
"post_tasks": [{"name": "post", "shell": "echo post"}],
"handlers": [{"name": "handler", "shell": "echo handler"}],
}
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 4
names = [t[0]["name"] for t in tasks]
# Order: tasks, pre_tasks, post_tasks, handlers (as defined in _iter_play_sections)
assert names == ["main", "pre", "post", "handler"]
def test_iter_tasks_block(self) -> None:
doc = [{"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]}]
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
# outer is not a play (no task sections) → yielded as bare task
# inner is yielded from block
assert len(tasks) == 2
assert tasks[0][0]["name"] == "outer"
assert tasks[1][0]["name"] == "inner"
def test_iter_tasks_block_in_play_section(self) -> None:
"""Block tasks within a play's tasks section are yielded."""
doc = {
"hosts": "all",
"tasks": [
{"name": "outer", "block": [{"name": "inner", "shell": "echo hi"}]},
],
}
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 2
assert tasks[0][0]["name"] == "outer"
assert tasks[1][0]["name"] == "inner"
def test_iter_tasks_play_list(self) -> None:
doc = [{"hosts": "all", "tasks": [{"name": "task1", "shell": "echo hi"}]}]
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 1
assert tasks[0][0]["name"] == "task1"
def test_iter_tasks_non_dict_items_skipped(self) -> None:
doc = ["string", 42, {"name": "task1", "shell": "echo hi"}]
tasks = list(AnsibleYAMLParser.iter_tasks(doc))
assert len(tasks) == 1
class TestViolationReporter:
def test_format_violation_with_line(self, tmp_path: Path) -> None:
result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, 42, "bad")
assert result == "foo.yml:42 — bad"
def test_format_violation_without_line(self, tmp_path: Path) -> None:
result = ViolationReporter.format_violation(tmp_path / "foo.yml", tmp_path, None, "bad")
assert result == "foo.yml — bad"
def test_format_violation_not_relative(self, tmp_path: Path) -> None:
other = Path("/other/path")
result = ViolationReporter.format_violation(other, tmp_path, 1, "bad")
assert str(other) in result
assert "bad" in result
def test_report_no_violations(self, capsys: pytest.CaptureFixture[str]) -> None:
ViolationReporter.report([], "test-tool")
captured = capsys.readouterr()
assert "OK" in captured.out
assert "test-tool" in captured.out
def test_report_with_violations(self, capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc_info:
ViolationReporter.report(["v1", "v2"], "test-tool")
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "FAIL" in captured.out
assert "v1" in captured.out
assert "v2" in captured.out
class TestDefaultAnsibleDirs:
def test_is_tuple(self) -> None:
assert isinstance(DEFAULT_ANSIBLE_DIRS, tuple)
def test_contains_expected(self) -> None:
assert "ansible/roles" in DEFAULT_ANSIBLE_DIRS
assert "ansible/playbooks" in DEFAULT_ANSIBLE_DIRS
+7
View File
@@ -150,6 +150,13 @@ class TestCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.validate_commit_msg", ["msg"])
@patch("devx.cli._run_module")
def test_ci_wait_for_checks(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "wait-for-checks", "--", "--job-name", "molecule-tests"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.wait_for_checks", ["--job-name", "molecule-tests"])
class TestToolsCommands:
@patch("devx.cli._run_module")
+57 -26
View File
@@ -1,4 +1,9 @@
"""Unit tests for scripts/ci/discover_runners.py."""
"""Unit tests for devx.ci.discover_runners (deprecated wrapper).
The wrapper re-exports from devx.molecule.discover_runners; these tests
verify backward compatibility by importing through the wrapper and
patching the canonical implementation's requests module.
"""
import json
from pathlib import Path
@@ -32,7 +37,7 @@ class TestGenerateIndices:
class TestQueryRunners:
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
"""Runners from repo, org, and admin levels are summed."""
responses = [
@@ -44,7 +49,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 6
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_skips_non_200(self, mock_get: MagicMock) -> None:
"""Non-200 responses (e.g., 403 for admin) are skipped."""
responses = [
@@ -56,7 +61,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
"""Network errors are caught and don't crash."""
mock_get.side_effect = [
@@ -67,7 +72,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
"""When all API calls fail, returns 0."""
mock_get.side_effect = [
@@ -78,7 +83,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on repo level is caught."""
responses = [
@@ -90,7 +95,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on org level is caught."""
responses = [
@@ -102,7 +107,7 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on admin level is caught."""
responses = [
@@ -114,14 +119,14 @@ class TestQueryRunners:
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
"""Network errors on all levels return 0."""
mock_get.side_effect = __import__("requests").RequestException("network error")
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_query_runners_403_no_warning(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""403 on instance-level runners should not produce a warning (expected without admin scope)."""
responses = [
@@ -135,7 +140,7 @@ class TestQueryRunners:
captured = capsys.readouterr()
assert "instance-level" not in captured.err
@patch("devx.ci.discover_runners.requests.get")
@patch("devx.molecule.discover_runners.requests.get")
def test_instance_level_non_403_warns(self, mock_get: MagicMock, capsys: pytest.CaptureFixture[str]) -> None:
"""Non-200, non-403 status on instance-level runners should produce a warning."""
responses = [
@@ -152,37 +157,37 @@ class TestQueryRunners:
class TestGetRunnerCount:
@patch("devx.ci.discover_runners.query_runners", return_value=5)
@patch("devx.molecule.discover_runners.query_runners", return_value=5)
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 5
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 4
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
"""When no token, skips API and uses env/default."""
@@ -192,7 +197,7 @@ class TestGetRunnerCount:
class TestMain:
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_default_output(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
@@ -200,28 +205,28 @@ class TestMain:
assert "count=3" in result.output
assert 'indices=["1", "2", "3"]' in result.output
@patch("devx.ci.discover_runners.get_runner_count", return_value=5)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=5)
def test_count_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "5"
@patch("devx.ci.discover_runners.get_runner_count", return_value=4)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=4)
def test_indices_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
@patch("devx.ci.discover_runners.get_runner_count", return_value=1)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=1)
def test_single_runner(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1"]
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
@@ -232,14 +237,14 @@ class TestMain:
assert "runner-count=3" in content
assert "runner-indices=" in content
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
@patch("devx.ci.discover_runners.get_runner_count", return_value=2)
@patch("devx.molecule.discover_runners.get_runner_count", return_value=2)
def test_explicit_owner_and_repo(self, mock_count: MagicMock) -> None:
"""When --owner and --repo are provided, env vars are not used."""
runner = CliRunner()
@@ -251,8 +256,8 @@ class TestMain:
assert "myorg" in args
assert "myrepo" in args
@patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
@patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
"""When no token is available, runner discovery falls back to env/default."""
runner = CliRunner()
@@ -261,3 +266,29 @@ class TestMain:
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
class TestDeprecationWrapper:
def test_re_exports_canonical_symbols(self) -> None:
"""The wrapper re-exports the canonical implementation's symbols."""
from devx.ci import discover_runners as ci_mod
from devx.molecule import discover_runners as mol_mod
assert ci_mod.query_runners is mol_mod.query_runners
assert ci_mod.get_runner_count is mol_mod.get_runner_count
assert ci_mod.generate_indices is mol_mod.generate_indices
assert ci_mod.main is mol_mod.main
assert ci_mod.DEFAULT_MAX_RUNNERS is mol_mod.DEFAULT_MAX_RUNNERS
def test_emit_deprecation_warning(self) -> None:
"""_emit_deprecation_warning issues a DeprecationWarning."""
import warnings
from devx.ci.discover_runners import _emit_deprecation_warning
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
_emit_deprecation_warning()
assert len(caught) == 1
assert issubclass(caught[0].category, DeprecationWarning)
assert "deprecated" in str(caught[0].message)
+89
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import platform
import urllib.error
from pathlib import Path
from unittest.mock import patch
@@ -68,6 +69,67 @@ class TestDownload:
mock_urlopen.assert_called_once()
assert dest.read_bytes() == b"data"
def test_download_retries_on_transient_error(self, tmp_path: Path) -> None:
"""Download retries on URLError then succeeds."""
dest = tmp_path / "file.bin"
call_count = [0]
class _FakeResponse:
def __init__(self) -> None:
self._sent = False
def __enter__(self) -> _FakeResponse:
return self
def __exit__(self, *args: object) -> None:
pass
def read(self, n: int = -1) -> bytes:
if self._sent:
return b""
self._sent = True
return b"data"
def _flaky_urlopen(url: str, timeout: int = 60):
call_count[0] += 1
if call_count[0] < 2:
raise urllib.error.URLError("Remote end closed connection")
return _FakeResponse()
with patch("urllib.request.urlopen", side_effect=_flaky_urlopen):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == 2
assert dest.read_bytes() == b"data"
def test_download_fails_after_max_retries(self, tmp_path: Path) -> None:
"""Download raises after MAX_DOWNLOAD_RETRIES attempts."""
dest = tmp_path / "file.bin"
call_count = [0]
def _always_fail(url: str, timeout: int = 60):
call_count[0] += 1
raise urllib.error.URLError("Remote end closed connection")
with patch("urllib.request.urlopen", side_effect=_always_fail):
with pytest.raises(urllib.error.URLError):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == install_tools.MAX_DOWNLOAD_RETRIES
assert not dest.exists()
def test_download_no_retry_on_non_transient_error(self, tmp_path: Path) -> None:
"""Download does not retry on non-network errors (e.g. ValueError)."""
dest = tmp_path / "file.bin"
call_count = [0]
def _fail_with_value_error(url: str, timeout: int = 60):
call_count[0] += 1
raise ValueError("not a network error")
with patch("urllib.request.urlopen", side_effect=_fail_with_value_error):
with pytest.raises(ValueError):
install_tools._download("https://example.com/file", dest, _sleep=lambda _: None)
assert call_count[0] == 1
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
@@ -233,6 +295,33 @@ class TestInstallTea:
assert install_tools.install_tea() is True
assert (tmp_path / "tea").exists()
def test_install_fallback_to_second_url(self, tmp_path: Path) -> None:
"""First URL fails (403), second URL succeeds."""
call_count = [0]
def _download_side_effect(url: str, dest: Path) -> None:
call_count[0] += 1
if call_count[0] == 1:
raise OSError("HTTP Error 403: Forbidden")
Path(dest).write_bytes(b"binary")
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_download_side_effect):
assert install_tools.install_tea() is True
assert (tmp_path / "tea").exists()
assert call_count[0] == 2
def test_install_all_urls_fail(self, tmp_path: Path) -> None:
"""All URLs fail — should raise ClickException."""
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=OSError("403 Forbidden")):
with pytest.raises(ClickException, match="Failed to download tea"):
install_tools.install_tea()
class TestInstallHadolint:
def test_already_installed(self) -> None:
+192
View File
@@ -0,0 +1,192 @@
"""Unit tests for devx.molecule.molecule_changed.
Verifies that the script correctly detects changed roles and maps
them to make targets.
"""
from __future__ import annotations
from unittest.mock import patch
from click.testing import CliRunner
from devx.molecule.molecule_changed import (
detect_changed_roles,
get_changed_files,
main,
roles_to_targets,
)
def test_detect_role_change():
"""A file in ansible/roles/<role>/ maps to that role."""
files = ["ansible/roles/docker_base/tasks/main.yml"]
roles = detect_changed_roles(files)
assert "docker_base" in roles
def test_detect_playbook_change():
"""A playbook change maps to its included roles."""
files = ["ansible/playbooks/deploy-observability.yml"]
roles = detect_changed_roles(files)
assert "observability" in roles
assert "docker_base" in roles
assert "zitadel" in roles
def test_detect_shared_infra_triggers_all():
"""ansible.cfg change triggers all roles."""
files = ["ansible/ansible.cfg"]
roles = detect_changed_roles(files)
assert len(roles) == 10 # all roles
def test_detect_no_ansible_changes():
"""Non-Ansible files don't trigger any roles."""
files = ["scripts/molecule_changed.py", "Makefile"]
roles = detect_changed_roles(files)
assert len(roles) == 0
def test_roles_to_targets():
"""Role names map to make targets."""
targets = roles_to_targets({"docker_base", "zitadel"})
assert "molecule-docker-base" in targets
assert "molecule-zitadel" in targets
def test_roles_to_targets_unknown_role():
"""Unknown roles are silently skipped."""
targets = roles_to_targets({"docker_base", "unknown_role"})
assert targets == ["molecule-docker-base"]
def test_main_no_changes():
"""When no files changed, outputs message to stderr."""
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0
assert "No changed files" in result.output
def test_main_print_targets():
"""--print-targets outputs make targets."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/docker_base/tasks/main.yml"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0
assert "molecule-docker-base" in result.output
def test_main_print_roles():
"""--print-roles outputs role names."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/zitadel/tasks/main.yml"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-roles"])
assert result.exit_code == 0
assert "zitadel" in result.output
def test_main_no_ansible_changes():
"""When only non-Ansible files changed, outputs no scenarios message."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["scripts/molecule_changed.py"],
):
runner = CliRunner()
result = runner.invoke(main, ["--print-targets"])
assert result.exit_code == 0
assert "No molecule scenarios" in result.output
def test_get_changed_files_with_mock():
"""get_changed_files returns files from git diff."""
with patch("devx.molecule.molecule_changed._run_git", return_value="file1\nfile2\n"):
files = get_changed_files("origin/master")
assert files == ["file1", "file2"]
def test_get_changed_files_falls_back_to_master():
"""When base ref has no diff, falls back to master."""
calls: list[list[str]] = []
def mock_git(args):
calls.append(args)
# First call (origin/master) returns empty, second (master) returns files
if "origin/master...HEAD" in args[2]:
return ""
return "ansible/roles/docker_base/tasks/main.yml\n"
with patch("devx.molecule.molecule_changed._run_git", side_effect=mock_git):
files = get_changed_files("origin/master")
assert files == ["ansible/roles/docker_base/tasks/main.yml"]
assert len(calls) == 2
def test_get_changed_files_empty():
"""When no changes in either ref, returns empty list."""
with patch("devx.molecule.molecule_changed._run_git", return_value=""):
files = get_changed_files("origin/master")
assert files == []
def test_detect_molecule_shared_path():
"""ansible/molecule/ change triggers all roles."""
files = ["ansible/molecule/Dockerfile"]
roles = detect_changed_roles(files)
assert len(roles) == 10
def test_detect_requirements_yml_triggers_all():
"""ansible/requirements.yml change triggers all roles."""
files = ["ansible/requirements.yml"]
roles = detect_changed_roles(files)
assert len(roles) == 10
def test_detect_configure_oidc_playbook():
"""configure-oidc.yml maps to sso_config and app_container."""
files = ["ansible/playbooks/configure-oidc.yml"]
roles = detect_changed_roles(files)
assert "sso_config" in roles
assert "app_container" in roles
def test_detect_prepare_vms_playbook():
"""prepare-vms.yml maps to all base roles."""
files = ["ansible/playbooks/prepare-vms.yml"]
roles = detect_changed_roles(files)
assert "docker_base" in roles
assert "app_hardening" in roles
assert "storage" in roles
assert "disk_cleanup" in roles
assert "crowdsec" in roles
def test_detect_deploy_customer_playbook():
"""deploy-customer.yml maps to its roles."""
files = ["ansible/playbooks/deploy-customer.yml"]
roles = detect_changed_roles(files)
assert "app_container" in roles
assert "docker_base" in roles
assert "app_hardening" in roles
assert "sso_config" in roles
def test_main_default_base():
"""main() with no --base uses origin/master."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
return_value=["ansible/roles/zitadel/tasks/main.yml"],
) as mock:
runner = CliRunner()
result = runner.invoke(main, ["--print-roles"])
assert result.exit_code == 0
mock.assert_called_once_with("origin/master")
+43
View File
@@ -114,6 +114,49 @@ class TestInstallAnsibleCollections:
_install_ansible_collections(".venv/bin")
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:
@patch("devx.tools.setup.shutil.which", return_value=None)
+1
View File
@@ -52,6 +52,7 @@ class TestInstallInImage:
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "--no-cache-dir" in cmd
assert "--no-deps" in cmd
assert "-e" in cmd
assert "." in cmd
# No extras → spec is "."
@@ -0,0 +1,367 @@
"""Unit tests for devx.tools.check_ansible_no_log."""
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_ansible_no_log import _check_task, check_directory, main
def _make_task(name: str, action: str, value: str, **extra: object) -> dict:
"""Build a minimal task dict for testing."""
task: dict = {"name": name, action: value}
task.update(extra)
return task
class TestCheckTask:
def test_task_with_secret_and_no_log_passes(self):
task = _make_task(
"Safe task",
"ansible.builtin.shell",
"echo {{ _secrets.mattermost_admin_password }}",
no_log=True,
)
assert _check_task(task, Path("test.yml"), 1) == []
def test_task_with_secret_and_no_no_log_fails(self):
task = _make_task(
"Unsafe task",
"ansible.builtin.shell",
"echo {{ _secrets.mattermost_admin_password }}",
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
assert "no_log" in violations[0]
def test_task_without_secret_passes(self):
task = _make_task(
"Normal task",
"ansible.builtin.shell",
"echo hello world",
)
assert _check_task(task, Path("test.yml"), 1) == []
def test_task_with_jinja_no_log_passes(self):
task = _make_task(
"Safe task with jinja no_log",
"ansible.builtin.shell",
"echo {{ _secrets.mattermost_admin_password }}",
no_log="{{ not (debug_mode | default(false) | bool) }}",
)
assert _check_task(task, Path("test.yml"), 1) == []
def test_task_with_password_in_name_only_no_false_positive(self):
"""Task name contains 'password' but no secret value — should not flag."""
task = _make_task(
"Configure passwdqc in common-password",
"ansible.builtin.lineinfile",
"password required pam_passwdqc.so min=disabled,disabled,16,12,8",
)
assert _check_task(task, Path("test.yml"), 1) == []
def test_task_with_password_in_module_param_no_false_positive(self):
"""Module param named 'password' but value is a literal — no Jinja."""
task = {
"name": "Set user password",
"ansible.builtin.user": {
"name": "deploy",
"password_lock": True,
},
}
assert _check_task(task, Path("test.yml"), 1) == []
def test_task_with_vault_password_variable_fails(self):
task = _make_task(
"Unsafe vault task",
"ansible.builtin.shell",
"echo {{ vault_zitadel_db_password }}",
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_nested_dict_secret_fails(self):
"""Secrets in nested dict values (e.g. set_fact) should be caught."""
task = {
"name": "Set secrets",
"ansible.builtin.set_fact": {
"db_password": "{{ vault_db_password }}",
"api_key": "{{ vault_api_key }}",
},
}
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_no_log_none_passes(self):
"""no_log: None should count as not set (flagged)."""
task = _make_task(
"Unsafe task",
"ansible.builtin.shell",
"echo {{ _secrets.db_password }}",
no_log=None,
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_secret_in_list_value_fails(self):
"""Secrets inside list values should be caught."""
task = {
"name": "Task with list secret",
"ansible.builtin.set_fact": {
"items": ["{{ _secrets.api_key }}", "normal_value"],
},
}
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_api_key_secret_fails(self):
"""api_key in Jinja expression should be caught."""
task = _make_task(
"Unsafe task",
"ansible.builtin.shell",
"echo {{ my_api_key }}",
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_secret_in_jinja_fails(self):
"""_secret in Jinja expression should be caught."""
task = _make_task(
"Unsafe task",
"ansible.builtin.shell",
"echo {{ my_secret }}",
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_access_token_fails(self):
"""access_token in Jinja expression should be caught."""
task = _make_task(
"Unsafe task",
"ansible.builtin.shell",
"echo {{ my_access_token }}",
)
violations = _check_task(task, Path("test.yml"), 1)
assert len(violations) == 1
def test_task_with_non_secret_non_dict_non_list_value(self):
"""Non-str, non-dict, non-list values (e.g. int) should not crash."""
task = _make_task(
"Task with int",
"ansible.builtin.shell",
"echo hello",
some_int=42,
)
assert _check_task(task, Path("test.yml"), 1) == []
class TestCheckDirectory:
def test_clean_directory_passes(self, tmp_path: Path):
"""A directory with no secret-handling tasks should pass."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Normal task\n ansible.builtin.shell: echo hello\n changed_when: false\n"
)
assert check_directory(role_dir) == []
def test_unsafe_task_is_caught(self, tmp_path: Path):
"""A task with secrets but no no_log should be flagged."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n changed_when: false\n"
)
violations = check_directory(role_dir)
assert len(violations) == 1
assert "Unsafe task" in violations[0]
def test_molecule_files_are_skipped(self, tmp_path: Path):
"""Molecule test files should not be scanned."""
role_dir = tmp_path / "roles" / "test_role"
mol_dir = role_dir / "molecule" / "default" / "tasks"
mol_dir.mkdir(parents=True)
(mol_dir / "main.yml").write_text(
"- name: Unsafe task in molecule\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
)
assert check_directory(role_dir) == []
def test_playbook_format_is_parsed(self, tmp_path: Path):
"""Playbook files (list of plays with 'hosts') should be parsed."""
pb_dir = tmp_path / "playbooks"
pb_dir.mkdir(parents=True)
(pb_dir / "test.yml").write_text(
"---\n"
"- name: Test play\n"
" hosts: all\n"
" tasks:\n"
" - name: Unsafe task\n"
" ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
)
violations = check_directory(tmp_path)
assert len(violations) == 1
assert "Unsafe task" in violations[0]
def test_invalid_yaml_is_skipped(self, tmp_path: Path):
"""Invalid YAML files should be skipped, not crash."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text("{{ invalid yaml: [")
assert check_directory(role_dir) == []
def test_empty_yaml_doc_is_skipped(self, tmp_path: Path):
"""Empty YAML documents (None) should be skipped."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text("---\n")
assert check_directory(role_dir) == []
def test_non_dict_non_list_doc_is_skipped(self, tmp_path: Path):
"""YAML docs that are neither dict nor list should be skipped."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text("just a string\n")
assert check_directory(role_dir) == []
def test_task_file_with_non_dict_task_skipped(self, tmp_path: Path):
"""Non-dict items in a task list should be skipped."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- just a string\n- name: Safe task\n ansible.builtin.shell: echo hello\n"
)
assert check_directory(role_dir) == []
def test_secret_in_list_value_is_caught(self, tmp_path: Path):
"""Secrets inside list values should be caught."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Task with list secret\n"
" ansible.builtin.set_fact:\n"
" items:\n"
' - "{{ _secrets.api_key }}"\n'
" - normal_value\n"
)
violations = check_directory(role_dir)
assert len(violations) == 1
def test_single_play_dict_format(self, tmp_path: Path):
"""A playbook that's a bare dict (not list of plays) should be parsed."""
pb_dir = tmp_path / "playbooks"
pb_dir.mkdir(parents=True)
(pb_dir / "test.yml").write_text(
"---\n"
"name: Single play\n"
"hosts: all\n"
"tasks:\n"
" - name: Unsafe task\n"
" ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
)
violations = check_directory(tmp_path)
assert len(violations) == 1
def test_play_with_non_dict_play_skipped(self, tmp_path: Path):
"""Non-dict plays in a playbook list should be skipped."""
pb_dir = tmp_path / "playbooks"
pb_dir.mkdir(parents=True)
# First play is valid (makes is_plays=True), second is a non-dict
(pb_dir / "test.yml").write_text(
"---\n"
"- name: Safe play\n"
" hosts: all\n"
" tasks:\n"
" - name: Safe task\n"
" ansible.builtin.shell: echo hello\n"
'- "just a string as second play"\n'
)
assert check_directory(tmp_path) == []
def test_play_with_non_list_tasks_skipped(self, tmp_path: Path):
"""Plays where tasks is not a list should be skipped."""
pb_dir = tmp_path / "playbooks"
pb_dir.mkdir(parents=True)
(pb_dir / "test.yml").write_text('---\n- name: Play with bad tasks\n hosts: all\n tasks: "not a list"\n')
assert check_directory(tmp_path) == []
def test_play_with_non_dict_task_in_playbook(self, tmp_path: Path):
"""Non-dict tasks in a playbook should be skipped."""
pb_dir = tmp_path / "playbooks"
pb_dir.mkdir(parents=True)
(pb_dir / "test.yml").write_text(
"---\n"
"- name: Play\n"
" hosts: all\n"
" tasks:\n"
' - "just a string"\n'
" - name: Safe task\n"
" ansible.builtin.shell: echo hello\n"
)
assert check_directory(tmp_path) == []
def test_yaml_file_with_oserror_skipped(self, tmp_path: Path):
"""YAML files that can't be opened should be skipped."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
# Create a file that will cause OSError when opened
# (use a directory with .yml extension)
bad_file = role_dir / "tasks" / "main.yml"
bad_file.mkdir()
assert check_directory(role_dir) == []
class TestMain:
def test_main_passes_on_clean_dir(self, tmp_path: Path):
"""main() should exit 0 on a clean directory."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Normal task\n ansible.builtin.shell: echo hello\n changed_when: false\n"
)
runner = CliRunner()
result = runner.invoke(main, ["--path", str(role_dir)])
assert result.exit_code == 0
assert "OK" in result.output or "no_log" in result.output
def test_main_fails_on_unsafe_dir(self, tmp_path: Path):
"""main() should exit 1 when violations are found."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
)
runner = CliRunner()
result = runner.invoke(main, ["--path", str(role_dir)])
assert result.exit_code == 1
assert "Unsafe task" in result.output
def test_main_returns_2_on_missing_dir(self, tmp_path: Path):
"""main() should exit 2 when the directory doesn't exist."""
runner = CliRunner()
result = runner.invoke(main, ["--path", str(tmp_path / "nonexistent")])
assert result.exit_code == 2
def test_main_with_ansible_dir_option(self, tmp_path: Path):
"""main() --ansible-dir should work like --path."""
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text(
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
)
runner = CliRunner()
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
assert result.exit_code == 1
def test_main_no_path_no_ansible_dir_uses_default(self, tmp_path: Path, monkeypatch):
"""main() with no args uses DEFAULT_ANSIBLE_DIR."""
import devx.tools.check_ansible_no_log as mod
role_dir = tmp_path / "roles" / "test_role"
(role_dir / "tasks").mkdir(parents=True)
(role_dir / "tasks" / "main.yml").write_text("- name: Normal task\n ansible.builtin.shell: echo hello\n")
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIR", tmp_path)
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
@@ -0,0 +1,198 @@
"""Unit tests for devx.tools.check_ansible_no_state_absent_on_db."""
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_ansible_no_state_absent_on_db import _check_file, _find_task_files, main
class TestCheckFile:
def test_clean_file_no_db_paths(self, tmp_path: Path):
"""A file with no DB paths should produce no violations."""
p = tmp_path / "test.yml"
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app/data\n state: directory\n")
assert _check_file(p, tmp_path) == []
def test_state_absent_on_zitadel_db_fails(self, tmp_path: Path):
"""state: absent on zitadel-db path should be flagged."""
p = tmp_path / "test.yml"
p.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
)
violations = _check_file(p, tmp_path)
assert len(violations) >= 1
assert "state" in violations[0].lower() or "absent" in violations[0].lower()
def test_state_absent_on_var_lib_postgresql_fails(self, tmp_path: Path):
"""state: absent on /var/lib/postgresql/data should be flagged."""
p = tmp_path / "test.yml"
p.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /var/lib/postgresql/data\n state: absent\n"
)
violations = _check_file(p, tmp_path)
assert len(violations) >= 1
def test_state_absent_on_app_db_fails(self, tmp_path: Path):
"""state: absent on any *-db path should be flagged."""
p = tmp_path / "test.yml"
p.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/gitea-db\n state: absent\n"
)
violations = _check_file(p, tmp_path)
assert len(violations) >= 1
def test_state_absent_with_pg_upgrade_context_passes(self, tmp_path: Path):
"""state: absent near DB path with upgrade-postgres context should pass."""
p = tmp_path / "test.yml"
p.write_text(
"- name: PG upgrade — remove old data\n"
" ansible.builtin.file:\n"
" path: /opt/postgres/zitadel-db\n"
" state: absent\n"
" when: pg_version_changed | default(false)\n"
)
violations = _check_file(p, tmp_path)
assert violations == []
def test_state_absent_with_pg_version_context_passes(self, tmp_path: Path):
"""state: absent near DB path with PG_VERSION context should pass."""
p = tmp_path / "test.yml"
p.write_text(
"- name: PG upgrade\n"
" ansible.builtin.file:\n"
" path: /opt/postgres/zitadel-db\n"
" state: absent\n"
" when: PG_VERSION is defined\n"
)
violations = _check_file(p, tmp_path)
assert violations == []
def test_state_absent_with_allow_marker_passes(self, tmp_path: Path):
"""state: absent with lint:allow-state-absent comment should pass."""
p = tmp_path / "test.yml"
p.write_text(
"# lint:allow-state-absent\n"
"- name: Intentional wipe\n"
" ansible.builtin.file:\n"
" path: /opt/postgres/zitadel-db\n"
" state: absent\n"
)
violations = _check_file(p, tmp_path)
assert violations == []
def test_state_present_on_db_path_passes(self, tmp_path: Path):
"""state: present (not absent) on DB path should pass."""
p = tmp_path / "test.yml"
p.write_text(
"- name: Safe task\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: directory\n"
)
assert _check_file(p, tmp_path) == []
def test_nonexistent_file_returns_empty(self):
"""A nonexistent file should return no violations."""
assert _check_file(Path("/nonexistent/path/file.yml"), Path.cwd()) == []
def test_rm_rf_db_fails(self, tmp_path: Path):
"""rm -rf on a DB path should be flagged."""
p = tmp_path / "test.yml"
p.write_text("- name: Dangerous wipe\n ansible.builtin.shell: rm -rf /opt/postgres/zitadel-db\n")
violations = _check_file(p, tmp_path)
assert len(violations) >= 1
def test_relative_path_outside_repo(self, tmp_path: Path):
"""Files outside repo_root use the full path in display."""
p = tmp_path / "test.yml"
p.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
)
violations = _check_file(p, Path("/other/repo"))
assert len(violations) >= 1
assert str(tmp_path) in violations[0] or "test.yml" in violations[0]
class TestFindTaskFiles:
def test_find_yml_files_in_directory(self, tmp_path: Path):
"""Should find .yml files in a directory."""
(tmp_path / "tasks").mkdir()
(tmp_path / "tasks" / "main.yml").write_text("[]")
(tmp_path / "tasks" / "other.yaml").write_text("[]")
files = _find_task_files(tmp_path)
assert len(files) == 2
def test_skip_molecule_files(self, tmp_path: Path):
"""Should skip files in molecule directories."""
(tmp_path / "molecule").mkdir()
(tmp_path / "molecule" / "test.yml").write_text("[]")
(tmp_path / "main.yml").write_text("[]")
files = _find_task_files(tmp_path)
assert len(files) == 1
assert "molecule" not in files[0].parts
def test_single_file_input(self, tmp_path: Path):
"""Should return the file itself if it's a .yml file."""
f = tmp_path / "test.yml"
f.write_text("[]")
files = _find_task_files(f)
assert files == [f]
def test_nonexistent_path_returns_empty(self):
"""A path that is neither a file nor a dir should return []."""
files = _find_task_files(Path("/nonexistent/path/that/does/not/exist"))
assert files == []
def test_non_yaml_file_skipped(self, tmp_path: Path):
"""Non-YAML files should not be included."""
f = tmp_path / "readme.txt"
f.write_text("not yaml")
assert _find_task_files(f) == []
class TestMain:
def test_main_no_violations_exit_zero(self, tmp_path: Path):
"""main() with a clean file should exit 0."""
f = tmp_path / "clean.yml"
f.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
result = CliRunner().invoke(main, ["--path", str(f)])
assert result.exit_code == 0
assert "OK" in result.output
def test_main_with_violations_exit_one(self, tmp_path: Path):
"""main() with a state: absent on a DB path should exit 1."""
f = tmp_path / "dangerous.yml"
f.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
)
result = CliRunner().invoke(main, ["--path", str(f)])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_main_path_to_clean_file(self, tmp_path: Path):
"""main() --path pointing to a specific clean file should exit 0."""
f = tmp_path / "tasks.yml"
f.write_text("- name: Safe\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
result = CliRunner().invoke(main, ["--path", str(f)])
assert result.exit_code == 0
def test_main_default_dirs_no_violations(self, tmp_path: Path, monkeypatch):
"""main() with no --path scans default dirs and exits 0."""
import devx.tools.check_ansible_no_state_absent_on_db as mod
(tmp_path / "clean.yml").write_text(
"- name: Safe\n ansible.builtin.file:\n path: /opt/app\n state: directory\n"
)
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIRS", [tmp_path])
result = CliRunner().invoke(main, [])
assert result.exit_code == 0
assert "OK" in result.output
def test_main_custom_ansible_dirs(self, tmp_path: Path):
"""main() --ansible-dir should work."""
f = tmp_path / "dangerous.yml"
f.write_text(
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
)
result = CliRunner().invoke(main, ["--ansible-dir", str(tmp_path)])
assert result.exit_code == 1
@@ -0,0 +1,340 @@
"""Unit tests for devx.tools.check_ansible_patterns."""
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_ansible_patterns import (
_check_file,
_check_task,
_check_tasks,
_find_task_files,
_is_legitimate_devnull,
_is_legitimate_or_true,
main,
)
def _make_task(name: str, action: str, value: str, **extra: object) -> dict:
"""Build a minimal task dict for testing."""
task: dict = {"name": name, action: value}
task.update(extra)
return task
class TestIsLegitimateOrTrue:
def test_cleanup_task_name_is_legitimate(self):
assert _is_legitimate_or_true("docker rm old-container", "Remove old container")
def test_prune_task_name_is_legitimate(self):
assert _is_legitimate_or_true("docker image prune -f", "Prune unused images")
def test_docker_rm_command_is_legitimate(self):
assert _is_legitimate_or_true("docker rm -f mycontainer", "Some task")
def test_provision_task_is_not_legitimate(self):
assert not _is_legitimate_or_true("curl -X POST https://api/app || true", "Provision OIDC client")
def test_sync_task_name_is_legitimate(self):
assert _is_legitimate_or_true("psql -c 'ALTER USER' || true", "Sync PostgreSQL password")
class TestCheckTask:
def test_or_true_on_provision_task_fails(self, tmp_path: Path):
task = _make_task(
"Provision OIDC client",
"ansible.builtin.shell",
"curl -X POST https://zitadel/api || true",
)
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
assert len(violations) >= 1
assert "|| true" in violations[0]
def test_or_true_on_cleanup_task_passes(self, tmp_path: Path):
task = _make_task(
"Remove old container",
"ansible.builtin.shell",
"docker rm -f old-container || true",
)
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
def test_failed_when_false_on_provision_fails(self, tmp_path: Path):
task = _make_task(
"Provision OIDC client",
"ansible.builtin.shell",
"curl -X POST https://zitadel/api",
failed_when=False,
)
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
assert any("failed_when" in v for v in violations)
def test_failed_when_false_on_stop_passes(self, tmp_path: Path):
task = _make_task(
"Stop ZITADEL containers",
"ansible.builtin.shell",
"docker stop zitadel",
failed_when=False,
)
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
def test_failed_when_false_on_check_passes(self, tmp_path: Path):
task = _make_task(
"Check if ZITADEL is running",
"ansible.builtin.shell",
"docker inspect zitadel",
failed_when=False,
)
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
def test_allow_marker_in_name_passes(self, tmp_path: Path):
task = _make_task(
"Provision OIDC #lint:allow-failure-masking",
"ansible.builtin.shell",
"curl -X POST https://zitadel/api || true",
failed_when=False,
)
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
def test_safe_task_no_violations(self, tmp_path: Path):
task = _make_task(
"Create directory",
"ansible.builtin.file",
"path=/opt/app state=directory",
)
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
def test_relative_path_outside_repo(self, tmp_path: Path):
"""Files outside repo_root use the full path in display."""
task = _make_task(
"Provision OIDC",
"ansible.builtin.shell",
"curl || true",
)
other_dir = Path("/tmp/other")
violations = _check_task(task, other_dir / "test.yml", 1, tmp_path)
assert len(violations) >= 1
class TestCheckFile:
def test_clean_file_passes(self, tmp_path: Path):
p = tmp_path / "test.yml"
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
assert _check_file(p, tmp_path) == []
def test_dangerous_pattern_detected(self, tmp_path: Path):
p = tmp_path / "test.yml"
p.write_text(
"- name: Provision OIDC\n"
" ansible.builtin.shell: |\n"
" curl -X POST https://api/app || true\n"
" failed_when: false\n"
)
violations = _check_file(p, tmp_path)
assert len(violations) >= 1
def test_file_level_allow_marker_passes(self, tmp_path: Path):
p = tmp_path / "test.yml"
p.write_text(
"# lint:allow-failure-masking\n"
"- name: Provision OIDC\n"
" ansible.builtin.shell: |\n"
" curl -X POST https://api/app || true\n"
" failed_when: false\n"
)
assert _check_file(p, tmp_path) == []
def test_nonexistent_file_returns_empty(self):
assert _check_file(Path("/nonexistent/path/file.yml"), Path.cwd()) == []
def test_yaml_parse_error_returns_empty(self, tmp_path: Path):
p = tmp_path / "test.yml"
p.write_text("name: Provision OIDC\n shell: curl || true\n: invalid: [")
assert _check_file(p, tmp_path) == []
def test_dict_doc_playbook_with_tasks(self, tmp_path: Path):
p = tmp_path / "playbook.yml"
p.write_text(
"- hosts: all\n"
" tasks:\n"
" - name: Provision OIDC\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
)
violations = _check_file(p, tmp_path)
assert any("|| true" in v for v in violations)
def test_dict_doc_with_pre_tasks_and_post_tasks(self, tmp_path: Path):
p = tmp_path / "playbook.yml"
p.write_text(
"- hosts: all\n"
" pre_tasks:\n"
" - name: Provision secret\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
" post_tasks:\n"
" - name: Provision OIDC\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
" handlers:\n"
" - name: Provision password\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
)
violations = _check_file(p, tmp_path)
assert len(violations) >= 3
def test_block_tasks_in_list_item(self, tmp_path: Path):
p = tmp_path / "tasks.yml"
p.write_text(
"- name: Outer task\n"
" block:\n"
" - name: Provision OIDC\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
" - name: Provision secret\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
)
violations = _check_file(p, tmp_path)
assert any("|| true" in v for v in violations)
def test_empty_doc_skipped(self, tmp_path: Path):
p = tmp_path / "test.yml"
p.write_text("---\nnull\n---\n- name: Provision OIDC\n ansible.builtin.shell: curl || true\n")
violations = _check_file(p, tmp_path)
assert any("|| true" in v for v in violations)
def test_pure_dict_doc_with_tasks(self, tmp_path: Path):
p = tmp_path / "playbook.yml"
p.write_text(
"hosts: all\n"
"tasks:\n"
" - name: Provision OIDC\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
)
violations = _check_file(p, tmp_path)
assert any("|| true" in v for v in violations)
class TestIsLegitimateDevnull:
def test_cleanup_task_is_legitimate(self):
assert _is_legitimate_devnull("docker rm old-container 2>/dev/null", "Remove old container")
def test_provision_task_is_not_legitimate(self):
assert not _is_legitimate_devnull("curl -X POST https://api/app 2>/dev/null", "Provision OIDC client")
class TestCheckTasks:
def test_tasks_section_checked(self, tmp_path: Path):
doc = {
"tasks": [
{"name": "Provision OIDC", "ansible.builtin.shell": "curl || true"},
],
}
errors: list[str] = []
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
assert any("|| true" in e for e in errors)
def test_block_inside_tasks_section(self, tmp_path: Path):
doc = {
"tasks": [
{
"name": "Outer",
"block": [
{"name": "Provision secret", "ansible.builtin.shell": "curl || true"},
],
},
],
}
errors: list[str] = []
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
assert any("|| true" in e for e in errors)
def test_non_list_section_ignored(self, tmp_path: Path):
doc = {"tasks": "not a list"}
errors: list[str] = []
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
assert errors == []
def test_non_dict_task_ignored(self, tmp_path: Path):
doc = {"tasks": ["just a string"]}
errors: list[str] = []
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
assert errors == []
class TestFindTaskFiles:
def test_single_file(self, tmp_path: Path):
p = tmp_path / "main.yml"
p.write_text("- name: test\n")
assert _find_task_files(p) == [p]
def test_single_yaml_file(self, tmp_path: Path):
p = tmp_path / "main.yaml"
p.write_text("- name: test\n")
assert _find_task_files(p) == [p]
def test_non_yaml_file_returns_empty(self, tmp_path: Path):
p = tmp_path / "main.txt"
p.write_text("hello\n")
assert _find_task_files(p) == []
def test_directory_finds_yaml_files(self, tmp_path: Path):
(tmp_path / "a.yml").write_text("- name: a\n")
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "b.yaml").write_text("- name: b\n")
(tmp_path / "ignore.txt").write_text("nope\n")
result = _find_task_files(tmp_path)
names = {f.name for f in result}
assert names == {"a.yml", "b.yaml"}
def test_directory_skips_molecule(self, tmp_path: Path):
(tmp_path / "a.yml").write_text("- name: a\n")
(tmp_path / "molecule").mkdir()
(tmp_path / "molecule" / "scenario.yml").write_text("- name: mol\n")
result = _find_task_files(tmp_path)
assert all("molecule" not in f.parts for f in result)
def test_nonexistent_path_returns_empty(self):
assert _find_task_files(Path("/nonexistent/path/xyz")) == []
class TestMain:
def test_main_clean_file_exit_zero(self, tmp_path: Path):
p = tmp_path / "clean.yml"
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
result = CliRunner().invoke(main, ["--path", str(p)])
assert result.exit_code == 0
assert "OK" in result.output
def test_main_violation_exit_one(self, tmp_path: Path):
p = tmp_path / "bad.yml"
p.write_text(
"- name: Provision OIDC\n"
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
" failed_when: false\n"
)
result = CliRunner().invoke(main, ["--path", str(p)])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_main_directory(self, tmp_path: Path):
(tmp_path / "clean.yml").write_text(
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
)
result = CliRunner().invoke(main, ["--path", str(tmp_path)])
assert result.exit_code == 0
def test_main_default_dirs(self, tmp_path: Path, monkeypatch):
import devx.tools.check_ansible_patterns as mod
(tmp_path / "clean.yml").write_text(
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
)
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIRS", [tmp_path])
result = CliRunner().invoke(main, [])
assert result.exit_code == 0
assert "OK" in result.output
def test_main_custom_ansible_dirs(self, tmp_path: Path):
(tmp_path / "bad.yml").write_text(
"- name: Provision OIDC\n ansible.builtin.shell: curl -X POST https://api/app || true\n"
)
result = CliRunner().invoke(main, ["--ansible-dir", str(tmp_path)])
assert result.exit_code == 1
@@ -268,6 +268,18 @@ class TestCheckFile:
errors = _check_file(f, tmp_path)
assert len(errors) == 2
def test_check_file_os_error(self, tmp_path: Path, monkeypatch) -> None:
"""OSError reading a file returns empty errors (not a crash)."""
f = tmp_path / "playbook.yml"
f.write_text("- name: ok\n set_fact:\n x: 1\n")
def _raise(*args, **kwargs):
raise OSError("disk error")
monkeypatch.setattr(Path, "read_text", _raise)
errors = _check_file(f, tmp_path)
assert errors == []
class TestMain:
def test_passes_when_clean(self, tmp_path: Path):
+264
View File
@@ -0,0 +1,264 @@
"""Unit tests for devx.tools.check_jinja_expr.
Verifies that the check correctly validates Jinja2 expressions,
catches reversed strftime filter arguments (the OBL-INFRA-508 bug),
and passes on valid expressions.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_jinja_expr import (
_check_file,
_default_ansible_dirs,
_extract_expressions,
_render_expression,
main,
)
def test_render_valid_expression():
"""Valid Jinja expression renders without error."""
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime(1735689600)")
assert ok
def test_render_reversed_strftime_args():
"""Reversed strftime filter args are detected as an error."""
ok, msg = _render_expression("(now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00')")
assert not ok
assert "reversed" in msg.lower()
def test_render_correct_strftime_args():
"""Correct strftime filter args pass."""
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime((now().timestamp() | int) + 3600)")
assert ok
def test_render_unknown_filter():
"""Unknown filter is reported as an error."""
ok, msg = _render_expression("'test' | nonexistent_filter")
assert not ok
assert "filter" in msg.lower()
def test_extract_skips_go_templates():
"""Go template syntax ({{.Field}}) is not extracted."""
content = "cmd: docker inspect --format '{{.State.Running}}' container"
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_single_char():
"""Single-character fragments are not extracted."""
content = 'value: "{{ \' }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_multiline():
"""Multi-line expressions are skipped."""
content = 'value: "{{\n something\n}}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_unbalanced():
"""Expressions with unbalanced braces (from partial capture) are skipped."""
content = "value: \"{{ default({'k': {}}, true) }}\""
expressions = _extract_expressions(content)
# The regex captures {{ default({'k': {}} — unbalanced parens
# because the inner }} terminates the match early.
# All extracted expressions should have balanced braces.
for expr in expressions:
assert expr.count("{") == expr.count("}")
def test_extract_valid_expression():
"""Valid Jinja expressions are extracted."""
content = "value: \"{{ my_var | default('x') }}\""
expressions = _extract_expressions(content)
assert "my_var | default('x')" in expressions
def test_main_passes_on_clean_file(tmp_path: Path) -> None:
"""A file with valid expressions passes."""
test_file = tmp_path / "tasks.yml"
test_file.write_text("value: \"{{ my_var | default('x') }}\"\nother: \"{{ '%Y' | strftime(1735689600) }}\"\n")
runner = CliRunner()
result = runner.invoke(main, ["--path", str(test_file)])
assert result.exit_code == 0
def test_main_no_violations_empty_dir(tmp_path: Path) -> None:
"""An empty directory passes."""
runner = CliRunner()
result = runner.invoke(main, ["--path", str(tmp_path)])
assert result.exit_code == 0
def test_main_catches_reversed_strftime(tmp_path: Path) -> None:
"""A file with reversed strftime args is flagged."""
test_file = tmp_path / "test.yml"
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
runner = CliRunner()
result = runner.invoke(main, ["--path", str(test_file)])
assert result.exit_code == 1
assert "reversed" in result.output.lower()
def test_render_skips_undefined_var():
"""Undefined variables are skipped (MockDict returns mock for missing keys)."""
ok, _ = _render_expression("nonexistent_var_in_mock | upper")
assert ok
def test_render_skips_other_errors():
"""Non-filter errors from missing mocks are skipped."""
ok, _ = _render_expression("some_undefined.attr.method()")
assert ok
def test_extract_skips_backtick():
"""Backtick fragments are skipped (caught by single-char check)."""
content = 'value: "{{ ` }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_network_settings():
"""Expressions with .NetworkSettings. patterns are skipped."""
content = 'value: "{{ foo.NetworkSettings.IPAddress }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_unbalanced_parens():
"""Expressions with unbalanced parens are skipped."""
content = 'value: "{{ foo(bar }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_unbalanced_braces():
"""Expressions with unbalanced braces are skipped."""
content = 'value: "{{ foo{bar }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_unbalanced_brackets():
"""Expressions with unbalanced brackets are skipped."""
content = 'value: "{{ foo[0 }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_control_flow():
"""Control flow fragments starting with % are skipped."""
content = 'value: "{{ % if x }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_check_file_outside_repo(tmp_path: Path) -> None:
"""Files outside REPO_ROOT are handled (no relative_to error)."""
test_file = tmp_path / "test.yml"
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
violations = _check_file(test_file, Path("/other/repo"))
assert len(violations) == 1
assert "reversed" in violations[0].lower()
def test_render_mock_dict_missing_key():
"""MockDict returns a mock for missing keys (no UndefinedError)."""
ok, _ = _render_expression("undefined_var.some_attr | upper")
assert ok
def test_render_syntax_error():
"""Syntax errors are reported as failures."""
ok, msg = _render_expression("{{ invalid syntax +")
assert not ok
assert "Syntax error" in msg
def test_render_unknown_filter_error():
"""Unknown filters are reported as failures (not skipped)."""
ok, msg = _render_expression("'test' | nonexistent_filter")
assert not ok
assert "filter" in msg.lower()
def test_render_generic_exception_skipped():
"""Non-filter exceptions from missing mocks are skipped."""
# replace() with no args triggers TypeError (missing required args)
# which is not a filter-not-found or strftime error — should be skipped.
ok, msg = _render_expression("my_var | replace")
assert ok
assert "Skipped" in msg
def test_default_ansible_dirs():
"""_default_ansible_dirs returns playbooks and roles paths."""
dirs = _default_ansible_dirs()
assert Path.cwd() / "ansible" / "playbooks" in dirs
assert Path.cwd() / "ansible" / "roles" in dirs
def test_main_default_dirs(tmp_path: Path) -> None:
"""Running with no --path scans default dirs (uses small temp fixture)."""
(tmp_path / "playbooks").mkdir()
(tmp_path / "roles").mkdir()
(tmp_path / "playbooks" / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
with patch(
"devx.tools.check_jinja_expr._default_ansible_dirs",
return_value=[tmp_path / "playbooks", tmp_path / "roles"],
):
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
def test_main_custom_ansible_dirs(tmp_path: Path) -> None:
"""--ansible-dir option works."""
(tmp_path / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
runner = CliRunner()
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
assert result.exit_code == 0
def test_extract_skips_println():
"""Expressions with 'println' (Go template) are skipped."""
content = 'value: "{{ println something }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_extract_skips_state_dot():
"""Expressions with .State. patterns are skipped."""
content = 'value: "{{ foo.State.Running }}"'
expressions = _extract_expressions(content)
assert len(expressions) == 0
def test_render_now_with_format():
"""now() with a format argument works."""
ok, _ = _render_expression("now('%Y-%m-%d')")
assert ok
def test_find_yaml_files_skips_molecule(tmp_path: Path) -> None:
"""Molecule directories are excluded from file search."""
from devx.tools.check_jinja_expr import _find_yaml_files
(tmp_path / "tasks.yml").write_text("value: test\n")
(tmp_path / "molecule").mkdir()
(tmp_path / "molecule" / "test.yml").write_text("value: test\n")
files = _find_yaml_files(tmp_path)
assert all("molecule" not in f.parts for f in files)
+272
View File
@@ -0,0 +1,272 @@
"""Unit tests for devx.ci.wait_for_checks."""
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.wait_for_checks import (
main,
poll_until_complete,
query_job_status,
)
def _mock_response(status_code: int = 200, json_data: object | None = None) -> MagicMock:
m = MagicMock()
m.status_code = status_code
if json_data is None:
m.json.side_effect = ValueError("no json")
else:
m.json.return_value = json_data
return m
class TestQueryJobStatus:
@patch("devx.ci.wait_for_checks.requests.get")
def test_returns_matching_jobs(self, mock_get: MagicMock) -> None:
"""Jobs whose name starts with the prefix are returned."""
mock_get.side_effect = [
_mock_response(200, [{"id": 1}, {"id": 2}]),
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
_mock_response(200, [{"name": "other-job", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
assert result[0]["name"] == "molecule-tests (1)"
assert result[0]["status"] == "completed"
assert result[0]["conclusion"] == "success"
@patch("devx.ci.wait_for_checks.requests.get")
def test_no_matching_jobs(self, mock_get: MagicMock) -> None:
"""When no job names match the prefix, returns empty list."""
mock_get.side_effect = [
_mock_response(200, [{"id": 1}]),
_mock_response(200, [{"name": "other-job", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert result == []
@patch("devx.ci.wait_for_checks.requests.get")
def test_api_error_returns_empty(self, mock_get: MagicMock) -> None:
"""Network errors on the runs endpoint return an empty list."""
import requests
mock_get.side_effect = requests.ConnectionError("down")
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert result == []
@patch("devx.ci.wait_for_checks.requests.get")
def test_non_200_returns_empty(self, mock_get: MagicMock) -> None:
"""Non-200 on runs endpoint returns empty list."""
mock_get.side_effect = [_mock_response(500, {"message": "err"})]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert result == []
@patch("devx.ci.wait_for_checks.requests.get")
def test_jobs_as_dict_with_jobs_key(self, mock_get: MagicMock) -> None:
"""Jobs endpoint returning {'jobs': [...]} dict is handled."""
mock_get.side_effect = [
_mock_response(200, [{"id": 1}]),
_mock_response(
200, {"jobs": [{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}]}
),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
assert result[0]["status"] == "in_progress"
@patch("devx.ci.wait_for_checks.requests.get")
def test_runs_as_dict_with_runs_key(self, mock_get: MagicMock) -> None:
"""Runs endpoint returning {'runs': [...]} dict is handled."""
mock_get.side_effect = [
_mock_response(200, {"runs": [{"id": 1}]}),
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
@patch("devx.ci.wait_for_checks.requests.get")
def test_jobs_endpoint_error_skips_run(self, mock_get: MagicMock) -> None:
"""A failed jobs query for one run doesn't abort the whole call."""
mock_get.side_effect = [
_mock_response(200, [{"id": 1}, {"id": 2}]),
_mock_response(500, {"message": "err"}),
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
@patch("devx.ci.wait_for_checks.requests.get")
def test_run_without_id_skipped(self, mock_get: MagicMock) -> None:
"""Runs missing an 'id' field are skipped."""
mock_get.side_effect = [
_mock_response(200, [{"foo": "bar"}, {"id": 1}]),
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
@patch("devx.ci.wait_for_checks.requests.get")
def test_jobs_query_exception_skips_run(self, mock_get: MagicMock) -> None:
"""A ConnectionError on the jobs endpoint for one run is skipped."""
import requests
mock_get.side_effect = [
_mock_response(200, [{"id": 1}, {"id": 2}]),
requests.ConnectionError("down"),
_mock_response(200, [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]),
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert len(result) == 1
@patch("devx.ci.wait_for_checks.requests.get")
def test_jobs_json_value_error_skips_run(self, mock_get: MagicMock) -> None:
"""A ValueError (bad JSON) on the jobs endpoint is skipped."""
mock_get.side_effect = [
_mock_response(200, [{"id": 1}]),
_mock_response(200), # json raises ValueError by default
]
result = query_job_status("https://api", "tok", "o/r", "molecule-tests")
assert result == []
class TestPollUntilComplete:
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_all_jobs_succeed(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""All jobs completed with success → returns 0."""
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}]
mock_mono.side_effect = [0.0, 0.0]
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
assert code == 0
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_job_fails(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""A job with non-success conclusion → returns 1."""
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "failure"}]
mock_mono.side_effect = [0.0, 0.0]
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
assert code == 1
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_no_require_success(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""With require_success=False, a failed job returns 0."""
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "completed", "conclusion": "failure"}]
mock_mono.side_effect = [0.0, 0.0]
code = poll_until_complete(
"https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10, require_success=False
)
assert code == 0
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_timeout(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""Jobs never complete → returns 2 after timeout."""
mock_query.return_value = [{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}]
# monotonic calls: deadline=0, while-check=0 (enter), sleep-calc=0, while-check=200 (exit)
mock_mono.side_effect = [0.0, 0.0, 0.0, 200.0]
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
assert code == 2
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_no_jobs_found_timeout(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""No matching jobs at all → returns 3."""
mock_query.return_value = []
# monotonic calls: deadline=0, while-check=0 (enter), sleep-calc=0, while-check=200 (exit)
mock_mono.side_effect = [0.0, 0.0, 0.0, 200.0]
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
assert code == 3
@patch("devx.ci.wait_for_checks.time.sleep")
@patch("devx.ci.wait_for_checks.time.monotonic")
@patch("devx.ci.wait_for_checks.query_job_status")
def test_in_progress_then_success(self, mock_query: MagicMock, mock_mono: MagicMock, mock_sleep: MagicMock) -> None:
"""First poll in_progress, second poll success → returns 0."""
mock_query.side_effect = [
[{"name": "molecule-tests (1)", "status": "in_progress", "conclusion": None}],
[{"name": "molecule-tests (1)", "status": "completed", "conclusion": "success"}],
]
# monotonic: deadline=0, while=0 (enter), sleep-calc=0, while=5 (enter), success→return
mock_mono.side_effect = [0.0, 0.0, 0.0, 5.0]
code = poll_until_complete("https://api", "tok", "o/r", "molecule-tests", timeout=100, interval=10)
assert code == 0
class TestMain:
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
def test_success_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
assert result.exit_code == 0
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=1)
def test_failure_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
assert result.exit_code == 1
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=2)
def test_timeout_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
assert result.exit_code == 2
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=3)
def test_api_error_exit_code(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
assert result.exit_code == 3
def test_missing_job_name(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "o/r"])
assert result.exit_code != 0
def test_missing_repo(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_REPOSITORY", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests"])
assert result.exit_code != 0
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
def test_repo_from_env(self, mock_poll: MagicMock, mock_token: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_REPOSITORY", "oblachno-oss/grm")
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests"])
assert result.exit_code == 0
args, kwargs = mock_poll.call_args
assert args[2] == "oblachno-oss/grm"
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
def test_invalid_timeout(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r", "--timeout", "0"])
assert result.exit_code != 0
@patch("devx.ci.wait_for_checks.get_ci_token", return_value="tok")
@patch("devx.ci.wait_for_checks.poll_until_complete", return_value=0)
def test_invalid_interval(self, mock_poll: MagicMock, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r", "--poll-interval", "0"])
assert result.exit_code != 0
@patch("devx.ci.wait_for_checks.get_ci_token", side_effect=__import__("click").ClickException("no token"))
def test_no_token_exit_3(self, mock_token: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--job-name", "molecule-tests", "--repo", "o/r"])
assert result.exit_code == 3