Public Access
Compare commits
2
Commits
master
..
85a36d42cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85a36d42cd | ||
|
|
454fbaf56a |
@@ -172,7 +172,7 @@ Every change starts with a spec. No spec, no code.
|
|||||||
**CI gates (pre-merge):**
|
**CI gates (pre-merge):**
|
||||||
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked
|
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked
|
||||||
- `devx.ci.check_pr_size` — max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
|
- `devx.ci.check_pr_size` — max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
|
||||||
- `devx.ci.fast_molecule` — full `molecule test` for changed roles only (scoped, single platform)
|
- `devx.ci.fast_molecule` — converge+verify only for changed roles, single platform
|
||||||
|
|
||||||
**Nightly (infra only):**
|
**Nightly (infra only):**
|
||||||
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
|
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
|
||||||
|
|||||||
@@ -2,18 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [0.55.4] - 2026-09-23
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fail-open molecule selection and honest fast-path contract
|
|
||||||
|
|
||||||
## [0.55.3] - 2026-09-21
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Honor repo bandit config in badge quality check
|
|
||||||
|
|
||||||
## [0.55.2] - 2026-09-19
|
## [0.55.2] - 2026-09-19
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.55.4",
|
"devx>=0.55.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.55.4"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.55.2"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.55.4,<0.56"`).
|
> (for example, `"devx>=0.55.2,<0.56"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.55.4",
|
"devx>=0.55.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.55.4"` or `"devx>=0.55.4,<0.56"`.
|
Pin a specific version if needed: `"devx==0.55.2"` or `"devx>=0.55.2,<0.56"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# DEVX-177: Honest molecule selection — fail-open coverage and real command contract
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`devx.molecule.molecule_changed` and `devx.ci.fast_molecule` violate the
|
|
||||||
S09/REQ-9 honesty contract in three ways:
|
|
||||||
|
|
||||||
1. **Silent skips.** `detect_changed_roles` only knows four playbooks and
|
|
||||||
`ansible/roles/`. Changes to `restore.yml`, `deploy-sso-bridge.yml`,
|
|
||||||
`upgrade-postgres.yml`, `rolling-update-gitea.yml`,
|
|
||||||
`update-alertmanager.yml`, `build-image.yml`, `playbooks/_tasks/`,
|
|
||||||
`playbooks/tasks/`, or `ansible/group_vars/` trigger zero molecule
|
|
||||||
coverage — the fast path passes by skipping work.
|
|
||||||
2. **Phantom targets.** `ROLE_TARGET_MAP` includes `sso_config` (role
|
|
||||||
moved to the sso-bridge repo) and emits `molecule-crowdsec` /
|
|
||||||
`molecule-disk-cleanup` targets that do not exist in infra's Makefile.
|
|
||||||
Playbook mappings also inject absent roles, so `make molecule-changed`
|
|
||||||
can select nonexistent targets.
|
|
||||||
3. **Dishonest contract.** `fast_molecule` documents "converge + verify
|
|
||||||
only, no idempotence" and builds `molecule test -s X --destroy=never
|
|
||||||
--platform-name=...` commands that nothing executes — CI actually runs
|
|
||||||
full `molecule test -s X` via `run_molecule_scenario.py` (which does
|
|
||||||
include idempotence where the scenario defines it).
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
REQ-1: Fail-open coverage in `detect_changed_roles` — add the missing
|
|
||||||
playbook→role mappings; `ansible/group_vars/**` and any other
|
|
||||||
`ansible/playbooks/**` file (including `_tasks/`/`tasks/`) not explicitly
|
|
||||||
mapped select all molecule-covered roles. `ansible/environments/` stays
|
|
||||||
unmapped (env data is covered by unit/deploy tests, not molecule) and is
|
|
||||||
documented as such.
|
|
||||||
|
|
||||||
REQ-2: Selection only emits roles that exist — `detect_changed_roles`
|
|
||||||
gains a `roles_dir` parameter; role names from file paths and playbook
|
|
||||||
maps are kept only when `<roles_dir>/<role>` exists. Shared-path and
|
|
||||||
fail-open "all roles" resolution returns only directories under
|
|
||||||
`roles_dir` containing a `molecule/` dir (untestable roles select
|
|
||||||
nothing rather than phantom targets).
|
|
||||||
|
|
||||||
REQ-3: Honest fast-molecule contract — `build_molecule_commands` emits
|
|
||||||
exactly what the CI runner executes (`molecule test -s <scenario>`);
|
|
||||||
docstrings state the real sequence (full `molecule test` per scenario on
|
|
||||||
changed roles, single platform as configured by the scenario) instead of
|
|
||||||
the old "converge + verify only" claim.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- Update `test_molecule_changed.py`: unmapped playbook → all present
|
|
||||||
roles; `group_vars` change → all; mapped playbook → mapped roles only;
|
|
||||||
role absent from `roles_dir` → filtered out; shared path → only roles
|
|
||||||
with `molecule/` dirs.
|
|
||||||
- Update `test_fast_molecule.py`: emitted commands are `molecule test -s
|
|
||||||
<scenario>` with no `--destroy`/`--platform-name` flags.
|
|
||||||
- `make pytest-cov` (100% gate), `make lint-all`.
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
Merge → devx release publishes automatically → infra dep-PR bumps the
|
|
||||||
pin; the honest selection takes effect on the next infra CI run.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
Revert the squash commit; previous (under-covering) selection returns —
|
|
||||||
acceptable short-term because coverage only widens with this change.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] REQ-1: All `ansible/playbooks/**` and `ansible/group_vars/**`
|
|
||||||
changes select molecule coverage; unmapped files fail open to all
|
|
||||||
testable roles.
|
|
||||||
- [x] REQ-2: No nonexistent roles or make targets are emitted; `sso_config`
|
|
||||||
no longer appears when absent from `roles_dir`.
|
|
||||||
- [x] REQ-3: `build_molecule_commands` output matches the executed CI
|
|
||||||
command shape; module docstrings describe the real sequence.
|
|
||||||
- [x] Unit tests cover every new behavior; 100% coverage maintained.
|
|
||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.55.4",
|
"devx>=0.55.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.55.4",
|
"devx>=0.55.2",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
|
|||||||
molecule testing helpers for Ansible projects.
|
molecule testing helpers for Ansible projects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.55.4"
|
__version__ = "0.55.2"
|
||||||
|
|||||||
@@ -5,19 +5,14 @@
|
|||||||
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
|
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
|
||||||
playbook→role mapping and shared infrastructure paths).
|
playbook→role mapping and shared infrastructure paths).
|
||||||
|
|
||||||
Fast molecule = full ``molecule test`` for every scenario of each changed
|
Fast molecule = converge + verify only, single platform, no idempotence
|
||||||
role, on the scenario's configured platform. "Fast" means *scoped* (only
|
check. Used in pre-merge CI to get quick feedback on Ansible changes
|
||||||
affected roles, single platform) — never skipped phases: create, converge,
|
without running the full molecule suite (which runs nightly).
|
||||||
idempotence (when the scenario defines it), verify, and destroy all run,
|
|
||||||
exactly as ``scripts/run_molecule_scenario.py`` executes them in CI.
|
|
||||||
|
|
||||||
Used in pre-merge CI to get quick feedback on Ansible changes without
|
|
||||||
running the full all-roles/all-platforms suite (which runs nightly).
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m devx.ci.fast_molecule --base origin/master --head HEAD
|
python -m devx.ci.fast_molecule --base origin/master --head HEAD
|
||||||
|
|
||||||
Outputs the list of changed roles and the molecule commands CI runs.
|
Outputs the list of changed roles and the molecule commands to run.
|
||||||
In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
|
In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
|
||||||
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
|
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
|
||||||
"""
|
"""
|
||||||
@@ -53,19 +48,19 @@ def build_molecule_commands(
|
|||||||
roles_dir: str = "ansible/roles",
|
roles_dir: str = "ansible/roles",
|
||||||
platform: str = "ubuntu-2604",
|
platform: str = "ubuntu-2604",
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Build the molecule commands CI executes for changed roles.
|
"""Build molecule test commands for changed roles.
|
||||||
|
|
||||||
Emits exactly what ``scripts/run_molecule_scenario.py`` runs:
|
For each role, runs each scenario with converge + verify only
|
||||||
``molecule test -s <scenario>`` — the full sequence (create, converge,
|
(skip create/destroy between scenarios, skip idempotence).
|
||||||
idempotence, verify, destroy). The ``platform`` argument is accepted
|
|
||||||
for interface stability but is informational: the scenario's
|
|
||||||
``molecule.yml`` selects the platform, and CI distributes scenarios so
|
|
||||||
each runs on a single platform.
|
|
||||||
"""
|
"""
|
||||||
commands: list[str] = []
|
commands: list[str] = []
|
||||||
for role in sorted(roles):
|
for role in sorted(roles):
|
||||||
for scenario in get_molecule_scenarios(role, roles_dir):
|
scenarios = get_molecule_scenarios(role, roles_dir)
|
||||||
commands.append(f"molecule test -s {scenario}")
|
if not scenarios:
|
||||||
|
continue
|
||||||
|
for scenario in scenarios:
|
||||||
|
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
|
||||||
|
commands.append(cmd)
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +93,7 @@ def cli(
|
|||||||
write_github_output("fast-molecule-roles", "")
|
write_github_output("fast-molecule-roles", "")
|
||||||
return
|
return
|
||||||
|
|
||||||
roles = detect_changed_roles(files, roles_dir)
|
roles = detect_changed_roles(files)
|
||||||
if not roles:
|
if not roles:
|
||||||
click.echo("[fast-molecule] No Ansible roles changed.")
|
click.echo("[fast-molecule] No Ansible roles changed.")
|
||||||
if github_output:
|
if github_output:
|
||||||
|
|||||||
@@ -17,13 +17,8 @@ nextcloud, postgres-upgrade, simple-app), the base target runs all
|
|||||||
scenarios for that role.
|
scenarios for that role.
|
||||||
|
|
||||||
Playbooks that change also trigger molecule for the roles they include.
|
Playbooks that change also trigger molecule for the roles they include.
|
||||||
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/,
|
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
|
||||||
group_vars/) trigger all scenarios. Any ``ansible/playbooks/**`` file not
|
trigger all scenarios.
|
||||||
in the explicit map fails open to all testable roles — targeted selection
|
|
||||||
must never silently skip coverage.
|
|
||||||
|
|
||||||
Only roles that exist on disk and contain a ``molecule/`` directory are
|
|
||||||
selected, so emitted make targets always resolve.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -35,28 +30,26 @@ import click
|
|||||||
|
|
||||||
REPO_ROOT = Path.cwd()
|
REPO_ROOT = Path.cwd()
|
||||||
|
|
||||||
|
# Map role names to make targets.
|
||||||
def role_to_target(role: str) -> str:
|
ROLE_TARGET_MAP: dict[str, str] = {
|
||||||
"""Make target for a role: docker_base -> molecule-docker-base."""
|
"app_container": "molecule-app-container",
|
||||||
return f"molecule-{role.replace('_', '-')}"
|
"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).
|
# Playbooks that map to molecule scenarios (via roles they include).
|
||||||
# Any ansible/playbooks/** file NOT listed here fails open to all testable
|
|
||||||
# roles (REQ-1) — a missing entry must never mean "no coverage".
|
|
||||||
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
|
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
|
||||||
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
|
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
|
||||||
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening"],
|
"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"],
|
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"],
|
||||||
"ansible/playbooks/restore.yml": ["restore"],
|
|
||||||
"ansible/playbooks/upgrade-postgres.yml": ["app_container"],
|
|
||||||
"ansible/playbooks/rolling-update-gitea.yml": ["app_container"],
|
|
||||||
"ansible/playbooks/update-alertmanager.yml": ["observability"],
|
|
||||||
"ansible/playbooks/build-image.yml": ["docker_base", "crowdsec", "disk_cleanup"],
|
|
||||||
# sso_bridge role is checked out from the sso-bridge repo at deploy
|
|
||||||
# time — no molecule coverage exists in the consuming repo, so the
|
|
||||||
# explicit empty list documents "mapped, nothing local to test".
|
|
||||||
"ansible/playbooks/deploy-sso-bridge.yml": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Shared infrastructure that affects all molecule tests.
|
# Shared infrastructure that affects all molecule tests.
|
||||||
@@ -64,7 +57,6 @@ SHARED_PATHS = (
|
|||||||
"ansible/ansible.cfg",
|
"ansible/ansible.cfg",
|
||||||
"ansible/requirements.yml",
|
"ansible/requirements.yml",
|
||||||
"ansible/molecule/",
|
"ansible/molecule/",
|
||||||
"ansible/group_vars/",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
|
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
|
||||||
@@ -93,30 +85,8 @@ def get_changed_files(base: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _testable_roles(roles_dir: Path) -> set[str]:
|
def detect_changed_roles(changed_files: list[str]) -> set[str]:
|
||||||
"""Roles present on disk that carry a molecule/ dir (i.e. have scenarios)."""
|
"""Detect which roles have changed files."""
|
||||||
if not roles_dir.is_dir():
|
|
||||||
return set()
|
|
||||||
return {d.name for d in roles_dir.iterdir() if d.is_dir() and (d / "molecule").is_dir()}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_playbook_file(filepath: str) -> bool:
|
|
||||||
"""True for any file under ansible/playbooks/ (yml tasks included)."""
|
|
||||||
return filepath.startswith("ansible/playbooks/")
|
|
||||||
|
|
||||||
|
|
||||||
def detect_changed_roles(
|
|
||||||
changed_files: list[str],
|
|
||||||
roles_dir: str | Path = "ansible/roles",
|
|
||||||
) -> set[str]:
|
|
||||||
"""Detect which roles have changed files.
|
|
||||||
|
|
||||||
Implements: REQ-1, REQ-2 — fail open on unmapped ansible playbook or
|
|
||||||
shared-path changes; only roles that exist under ``roles_dir`` are
|
|
||||||
returned, so emitted targets always resolve.
|
|
||||||
"""
|
|
||||||
roles_path = Path(roles_dir)
|
|
||||||
all_roles = _testable_roles(roles_path)
|
|
||||||
roles: set[str] = set()
|
roles: set[str] = set()
|
||||||
|
|
||||||
for filepath in changed_files:
|
for filepath in changed_files:
|
||||||
@@ -129,23 +99,23 @@ def detect_changed_roles(
|
|||||||
# Check if file is a playbook that maps to roles
|
# Check if file is a playbook that maps to roles
|
||||||
if filepath in PLAYBOOK_ROLE_MAP:
|
if filepath in PLAYBOOK_ROLE_MAP:
|
||||||
roles.update(PLAYBOOK_ROLE_MAP[filepath])
|
roles.update(PLAYBOOK_ROLE_MAP[filepath])
|
||||||
elif _is_playbook_file(filepath):
|
|
||||||
# Unmapped playbook/_tasks file — fail open to all testable roles.
|
|
||||||
return set(all_roles)
|
|
||||||
|
|
||||||
# Check shared infrastructure — triggers all roles
|
# Check shared infrastructure — triggers all roles
|
||||||
for shared in SHARED_PATHS:
|
for shared in SHARED_PATHS:
|
||||||
if filepath.startswith(shared):
|
if filepath.startswith(shared):
|
||||||
return set(all_roles)
|
return set(ROLE_TARGET_MAP.keys())
|
||||||
|
|
||||||
# REQ-2: drop roles that don't exist on disk (e.g. sso_config after the
|
return roles
|
||||||
# role moved to the sso-bridge repo) so targets always resolve.
|
|
||||||
return {r for r in roles if (roles_path / r).is_dir()}
|
|
||||||
|
|
||||||
|
|
||||||
def roles_to_targets(roles: set[str]) -> list[str]:
|
def roles_to_targets(roles: set[str]) -> list[str]:
|
||||||
"""Convert role names to make targets (conventional molecule-<role>)."""
|
"""Convert role names to make targets."""
|
||||||
return [role_to_target(role) for role in sorted(roles)]
|
targets = []
|
||||||
|
for role in sorted(roles):
|
||||||
|
target = ROLE_TARGET_MAP.get(role)
|
||||||
|
if target:
|
||||||
|
targets.append(target)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -164,19 +134,14 @@ def roles_to_targets(roles: set[str]) -> list[str]:
|
|||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="Print role names (default if no --print-targets).",
|
help="Print role names (default if no --print-targets).",
|
||||||
)
|
)
|
||||||
@click.option(
|
def main(base: str, print_targets: bool, print_roles: bool) -> None:
|
||||||
"--roles-dir",
|
|
||||||
default="ansible/roles",
|
|
||||||
help="Directory containing Ansible roles (default: ansible/roles).",
|
|
||||||
)
|
|
||||||
def main(base: str, print_targets: bool, print_roles: bool, roles_dir: str) -> None:
|
|
||||||
"""Detect which Ansible roles changed and output molecule scenarios."""
|
"""Detect which Ansible roles changed and output molecule scenarios."""
|
||||||
changed_files = get_changed_files(base)
|
changed_files = get_changed_files(base)
|
||||||
if not changed_files:
|
if not changed_files:
|
||||||
click.echo("No changed files detected.", err=True)
|
click.echo("No changed files detected.", err=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
roles = detect_changed_roles(changed_files, roles_dir)
|
roles = detect_changed_roles(changed_files)
|
||||||
if not roles:
|
if not roles:
|
||||||
click.echo("No molecule scenarios affected by changes.", err=True)
|
click.echo("No molecule scenarios affected by changes.", err=True)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ class TestBuildMoleculeCommands:
|
|||||||
|
|
||||||
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
|
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
|
||||||
assert len(commands) == 2
|
assert len(commands) == 2
|
||||||
# REQ-3: emitted commands are exactly what run_molecule_scenario
|
assert all("molecule test -s default" in c for c in commands)
|
||||||
# executes — full `molecule test -s <scenario>`, no synthetic flags.
|
assert all("--destroy=never" in c for c in commands)
|
||||||
assert commands == ["molecule test -s default", "molecule test -s default"]
|
assert all("ubuntu-2604" in c for c in commands)
|
||||||
assert all("--destroy" not in c and "--platform-name" not in c for c in commands)
|
|
||||||
|
|
||||||
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
|
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
|
||||||
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
|
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
|
||||||
|
|||||||
@@ -1,186 +1,107 @@
|
|||||||
"""Unit tests for devx.molecule.molecule_changed.
|
"""Unit tests for devx.molecule.molecule_changed.
|
||||||
|
|
||||||
Verifies that the script correctly detects changed roles, fails open on
|
Verifies that the script correctly detects changed roles and maps
|
||||||
unmapped ansible paths, and only emits roles that exist on disk.
|
them to make targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.molecule.molecule_changed import (
|
from devx.molecule.molecule_changed import (
|
||||||
detect_changed_roles,
|
detect_changed_roles,
|
||||||
get_changed_files,
|
get_changed_files,
|
||||||
main,
|
main,
|
||||||
role_to_target,
|
|
||||||
roles_to_targets,
|
roles_to_targets,
|
||||||
)
|
)
|
||||||
|
|
||||||
TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability")
|
|
||||||
|
|
||||||
|
def test_detect_role_change():
|
||||||
@pytest.fixture()
|
|
||||||
def roles_dir(tmp_path: Path) -> Path:
|
|
||||||
"""Fake roles dir: 4 testable roles (molecule/ present) + 1 untestable."""
|
|
||||||
for role in TESTABLE_ROLES:
|
|
||||||
(tmp_path / role / "molecule" / "default").mkdir(parents=True)
|
|
||||||
(tmp_path / "untested_role").mkdir() # exists but no molecule/ dir
|
|
||||||
return tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_role_change(roles_dir: Path):
|
|
||||||
"""A file in ansible/roles/<role>/ maps to that role."""
|
"""A file in ansible/roles/<role>/ maps to that role."""
|
||||||
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
|
files = ["ansible/roles/docker_base/tasks/main.yml"]
|
||||||
assert roles == {"docker_base"}
|
roles = detect_changed_roles(files)
|
||||||
|
assert "docker_base" in roles
|
||||||
|
|
||||||
|
|
||||||
def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
|
def test_detect_playbook_change():
|
||||||
"""Changed role absent from roles_dir selects nothing (REQ-2)."""
|
"""A playbook change maps to its included roles."""
|
||||||
roles = detect_changed_roles(["ansible/roles/sso_config/tasks/main.yml"], roles_dir)
|
|
||||||
assert roles == set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_playbook_change(roles_dir: Path):
|
|
||||||
"""A mapped playbook maps to its included roles, filtered to disk."""
|
|
||||||
files = ["ansible/playbooks/deploy-observability.yml"]
|
files = ["ansible/playbooks/deploy-observability.yml"]
|
||||||
roles = detect_changed_roles(files, roles_dir)
|
roles = detect_changed_roles(files)
|
||||||
# zitadel + crowdsec are mapped but absent from the fake roles dir.
|
assert "observability" in roles
|
||||||
assert roles == {"observability", "docker_base"}
|
assert "docker_base" in roles
|
||||||
|
assert "zitadel" in roles
|
||||||
|
|
||||||
|
|
||||||
def test_detect_shared_infra_triggers_all(roles_dir: Path):
|
def test_detect_shared_infra_triggers_all():
|
||||||
"""ansible.cfg change triggers all testable roles only."""
|
"""ansible.cfg change triggers all roles."""
|
||||||
roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
|
files = ["ansible/ansible.cfg"]
|
||||||
assert roles == set(TESTABLE_ROLES)
|
roles = detect_changed_roles(files)
|
||||||
|
assert len(roles) == 10 # all roles
|
||||||
|
|
||||||
|
|
||||||
def test_detect_molecule_shared_path(roles_dir: Path):
|
def test_detect_no_ansible_changes():
|
||||||
"""ansible/molecule/ change triggers all testable roles."""
|
|
||||||
roles = detect_changed_roles(["ansible/molecule/Dockerfile"], roles_dir)
|
|
||||||
assert roles == set(TESTABLE_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_requirements_yml_triggers_all(roles_dir: Path):
|
|
||||||
"""ansible/requirements.yml change triggers all testable roles."""
|
|
||||||
roles = detect_changed_roles(["ansible/requirements.yml"], roles_dir)
|
|
||||||
assert roles == set(TESTABLE_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_group_vars_triggers_all(roles_dir: Path):
|
|
||||||
"""ansible/group_vars/ change triggers all testable roles (REQ-1)."""
|
|
||||||
roles = detect_changed_roles(["ansible/group_vars/all/images.yml"], roles_dir)
|
|
||||||
assert roles == set(TESTABLE_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_unmapped_playbook_fails_open(roles_dir: Path):
|
|
||||||
"""An unmapped playbook selects all testable roles (REQ-1)."""
|
|
||||||
roles = detect_changed_roles(["ansible/playbooks/new-deploy.yml"], roles_dir)
|
|
||||||
assert roles == set(TESTABLE_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_playbook_tasks_dir_fails_open(roles_dir: Path):
|
|
||||||
"""Shared playbook task files select all testable roles (REQ-1)."""
|
|
||||||
roles = detect_changed_roles(["ansible/playbooks/_tasks/upgrade-postgres-database.yml"], roles_dir)
|
|
||||||
assert roles == set(TESTABLE_ROLES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mapped_playbooks(roles_dir: Path):
|
|
||||||
"""Each newly mapped playbook selects its roles (REQ-1)."""
|
|
||||||
expectations = {
|
|
||||||
"ansible/playbooks/restore.yml": {"restore"},
|
|
||||||
"ansible/playbooks/upgrade-postgres.yml": {"app_container"},
|
|
||||||
"ansible/playbooks/rolling-update-gitea.yml": {"app_container"},
|
|
||||||
"ansible/playbooks/update-alertmanager.yml": {"observability"},
|
|
||||||
"ansible/playbooks/build-image.yml": {"docker_base"},
|
|
||||||
"ansible/playbooks/deploy-sso-bridge.yml": set(),
|
|
||||||
}
|
|
||||||
for playbook, expected in expectations.items():
|
|
||||||
assert detect_changed_roles([playbook], roles_dir) == expected, playbook
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_prepare_vms_playbook(roles_dir: Path):
|
|
||||||
"""prepare-vms.yml maps to all base roles present on disk."""
|
|
||||||
roles = detect_changed_roles(["ansible/playbooks/prepare-vms.yml"], roles_dir)
|
|
||||||
assert roles == {"docker_base"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_deploy_customer_playbook(roles_dir: Path):
|
|
||||||
"""deploy-customer.yml maps to its roles present on disk."""
|
|
||||||
roles = detect_changed_roles(["ansible/playbooks/deploy-customer.yml"], roles_dir)
|
|
||||||
assert roles == {"app_container", "docker_base"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_no_ansible_changes(roles_dir: Path):
|
|
||||||
"""Non-Ansible files don't trigger any roles."""
|
"""Non-Ansible files don't trigger any roles."""
|
||||||
roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
|
files = ["scripts/molecule_changed.py", "Makefile"]
|
||||||
assert roles == set()
|
roles = detect_changed_roles(files)
|
||||||
|
assert len(roles) == 0
|
||||||
|
|
||||||
def test_detect_environments_not_molecule_covered(roles_dir: Path):
|
|
||||||
"""ansible/environments/ data is not molecule-covered (documented)."""
|
|
||||||
roles = detect_changed_roles(["ansible/environments/staging/customers.yml"], roles_dir)
|
|
||||||
assert roles == set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_detect_missing_roles_dir():
|
|
||||||
"""A nonexistent roles_dir yields no roles (honest: nothing testable)."""
|
|
||||||
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], "/nonexistent")
|
|
||||||
assert roles == set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_role_to_target():
|
|
||||||
"""Role names map to conventional make targets."""
|
|
||||||
assert role_to_target("docker_base") == "molecule-docker-base"
|
|
||||||
assert role_to_target("app_hardening") == "molecule-app-hardening"
|
|
||||||
|
|
||||||
|
|
||||||
def test_roles_to_targets():
|
def test_roles_to_targets():
|
||||||
"""Role names map to make targets."""
|
"""Role names map to make targets."""
|
||||||
targets = roles_to_targets({"docker_base", "zitadel"})
|
targets = roles_to_targets({"docker_base", "zitadel"})
|
||||||
assert targets == ["molecule-docker-base", "molecule-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():
|
def test_main_no_changes():
|
||||||
"""When no files changed, outputs message to stderr."""
|
"""When no files changed, outputs message to stderr."""
|
||||||
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
|
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
|
||||||
result = CliRunner().invoke(main, ["--print-targets"])
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--print-targets"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No changed files" in result.output
|
assert "No changed files" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_main_print_targets(roles_dir: Path):
|
def test_main_print_targets():
|
||||||
"""--print-targets outputs make targets for existing roles."""
|
"""--print-targets outputs make targets."""
|
||||||
with patch(
|
with patch(
|
||||||
"devx.molecule.molecule_changed.get_changed_files",
|
"devx.molecule.molecule_changed.get_changed_files",
|
||||||
return_value=["ansible/roles/docker_base/tasks/main.yml"],
|
return_value=["ansible/roles/docker_base/tasks/main.yml"],
|
||||||
):
|
):
|
||||||
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--print-targets"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "molecule-docker-base" in result.output
|
assert "molecule-docker-base" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_main_print_roles(roles_dir: Path):
|
def test_main_print_roles():
|
||||||
"""--print-roles outputs role names."""
|
"""--print-roles outputs role names."""
|
||||||
with patch(
|
with patch(
|
||||||
"devx.molecule.molecule_changed.get_changed_files",
|
"devx.molecule.molecule_changed.get_changed_files",
|
||||||
return_value=["ansible/roles/restore/tasks/main.yml"],
|
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||||
):
|
):
|
||||||
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--print-roles"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "restore" in result.output
|
assert "zitadel" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_main_no_ansible_changes(roles_dir: Path):
|
def test_main_no_ansible_changes():
|
||||||
"""When only non-Ansible files changed, outputs no scenarios message."""
|
"""When only non-Ansible files changed, outputs no scenarios message."""
|
||||||
with patch(
|
with patch(
|
||||||
"devx.molecule.molecule_changed.get_changed_files",
|
"devx.molecule.molecule_changed.get_changed_files",
|
||||||
return_value=["scripts/molecule_changed.py"],
|
return_value=["scripts/molecule_changed.py"],
|
||||||
):
|
):
|
||||||
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--print-targets"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "No molecule scenarios" in result.output
|
assert "No molecule scenarios" in result.output
|
||||||
|
|
||||||
@@ -198,6 +119,7 @@ def test_get_changed_files_falls_back_to_master():
|
|||||||
|
|
||||||
def mock_git(args):
|
def mock_git(args):
|
||||||
calls.append(args)
|
calls.append(args)
|
||||||
|
# First call (origin/master) returns empty, second (master) returns files
|
||||||
if "origin/master...HEAD" in args[2]:
|
if "origin/master...HEAD" in args[2]:
|
||||||
return ""
|
return ""
|
||||||
return "ansible/roles/docker_base/tasks/main.yml\n"
|
return "ansible/roles/docker_base/tasks/main.yml\n"
|
||||||
@@ -215,12 +137,56 @@ def test_get_changed_files_empty():
|
|||||||
assert files == []
|
assert files == []
|
||||||
|
|
||||||
|
|
||||||
def test_main_default_base(roles_dir: Path):
|
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."""
|
"""main() with no --base uses origin/master."""
|
||||||
with patch(
|
with patch(
|
||||||
"devx.molecule.molecule_changed.get_changed_files",
|
"devx.molecule.molecule_changed.get_changed_files",
|
||||||
return_value=["ansible/roles/restore/tasks/main.yml"],
|
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||||
) as mock:
|
) as mock:
|
||||||
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--print-roles"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock.assert_called_once_with("origin/master")
|
mock.assert_called_once_with("origin/master")
|
||||||
|
|||||||
Reference in New Issue
Block a user