Compare commits

..
2 Commits
Author SHA1 Message Date
Emil Simeonov 85a36d42cd ci: retrigger validation with corrected PR title
CI / validate (pull_request) Successful in 59s
CI / auto-merge (pull_request) Successful in 14s
2026-09-21 19:36:15 +00:00
Emil Simeonov 454fbaf56a ci: docs fast-path, notify-failure scoping, post-merge queue 2026-09-21 19:36:15 +00:00
11 changed files with 159 additions and 322 deletions
+1 -1
View File
@@ -172,7 +172,7 @@ Every change starts with a spec. No spec, no code.
**CI gates (pre-merge):**
- `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.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):**
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
-12
View File
@@ -2,18 +2,6 @@
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
### Bug Fixes
+9 -9
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/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/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.55.4",
"devx>=0.55.2",
]
[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.55.4"`) or use a version constraint
> (for example, `"devx>=0.55.4,<0.56"`).
> `dependencies` (for example, `"devx==0.55.2"`) or use a version constraint
> (for example, `"devx>=0.55.2,<0.56"`).
### Optional extras
+8 -8
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/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/b5c21b71a4c057aa45a871c5cef1ddddd07458d5/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.55.4",
"devx>=0.55.2",
]
[tool.pip]
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
-76
View File
@@ -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.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.55.4",
"devx>=0.55.2",
]
[project.optional-dependencies]
dev = [
"devx>=0.55.4",
"devx>=0.55.2",
]
```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects.
"""
__version__ = "0.55.4"
__version__ = "0.55.2"
+14 -19
View File
@@ -5,19 +5,14 @@
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
playbookrole mapping and shared infrastructure paths).
Fast molecule = full ``molecule test`` for every scenario of each changed
role, on the scenario's configured platform. "Fast" means *scoped* (only
affected roles, single platform) never skipped phases: create, converge,
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).
Fast molecule = converge + verify only, single platform, no idempotence
check. Used in pre-merge CI to get quick feedback on Ansible changes
without running the full molecule suite (which runs nightly).
Usage:
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-
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
"""
@@ -53,19 +48,19 @@ def build_molecule_commands(
roles_dir: str = "ansible/roles",
platform: str = "ubuntu-2604",
) -> 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:
``molecule test -s <scenario>`` the full sequence (create, converge,
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.
For each role, runs each scenario with converge + verify only
(skip create/destroy between scenarios, skip idempotence).
"""
commands: list[str] = []
for role in sorted(roles):
for scenario in get_molecule_scenarios(role, roles_dir):
commands.append(f"molecule test -s {scenario}")
scenarios = get_molecule_scenarios(role, roles_dir)
if not scenarios:
continue
for scenario in scenarios:
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
commands.append(cmd)
return commands
@@ -98,7 +93,7 @@ def cli(
write_github_output("fast-molecule-roles", "")
return
roles = detect_changed_roles(files, roles_dir)
roles = detect_changed_roles(files)
if not roles:
click.echo("[fast-molecule] No Ansible roles changed.")
if github_output:
+30 -65
View File
@@ -17,13 +17,8 @@ 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/,
group_vars/) trigger all scenarios. Any ``ansible/playbooks/**`` file not
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.
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
trigger all scenarios.
"""
from __future__ import annotations
@@ -35,28 +30,26 @@ import click
REPO_ROOT = Path.cwd()
def role_to_target(role: str) -> str:
"""Make target for a role: docker_base -> molecule-docker-base."""
return f"molecule-{role.replace('_', '-')}"
# 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).
# 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]] = {
"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/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.
@@ -64,7 +57,6 @@ SHARED_PATHS = (
"ansible/ansible.cfg",
"ansible/requirements.yml",
"ansible/molecule/",
"ansible/group_vars/",
)
# 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 []
def _testable_roles(roles_dir: Path) -> set[str]:
"""Roles present on disk that carry a molecule/ dir (i.e. have scenarios)."""
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)
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:
@@ -129,23 +99,23 @@ def detect_changed_roles(
# Check if file is a playbook that maps to roles
if filepath in PLAYBOOK_ROLE_MAP:
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
for shared in SHARED_PATHS:
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
# role moved to the sso-bridge repo) so targets always resolve.
return {r for r in roles if (roles_path / r).is_dir()}
return roles
def roles_to_targets(roles: set[str]) -> list[str]:
"""Convert role names to make targets (conventional molecule-<role>)."""
return [role_to_target(role) for role in sorted(roles)]
"""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()
@@ -164,19 +134,14 @@ def roles_to_targets(roles: set[str]) -> list[str]:
is_flag=True,
help="Print role names (default if no --print-targets).",
)
@click.option(
"--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:
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, roles_dir)
roles = detect_changed_roles(changed_files)
if not roles:
click.echo("No molecule scenarios affected by changes.", err=True)
return
+3 -4
View File
@@ -40,10 +40,9 @@ class TestBuildMoleculeCommands:
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
assert len(commands) == 2
# REQ-3: emitted commands are exactly what run_molecule_scenario
# executes — full `molecule test -s <scenario>`, no synthetic flags.
assert commands == ["molecule test -s default", "molecule test -s default"]
assert all("--destroy" not in c and "--platform-name" not in c for c in commands)
assert all("molecule test -s default" in c for c in commands)
assert all("--destroy=never" in c for c in commands)
assert all("ubuntu-2604" in c for c in commands)
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
+91 -125
View File
@@ -1,186 +1,107 @@
"""Unit tests for devx.molecule.molecule_changed.
Verifies that the script correctly detects changed roles, fails open on
unmapped ansible paths, and only emits roles that exist on disk.
Verifies that the script correctly detects changed roles and maps
them to make targets.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from devx.molecule.molecule_changed import (
detect_changed_roles,
get_changed_files,
main,
role_to_target,
roles_to_targets,
)
TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability")
@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):
def test_detect_role_change():
"""A file in ansible/roles/<role>/ maps to that role."""
roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir)
assert roles == {"docker_base"}
files = ["ansible/roles/docker_base/tasks/main.yml"]
roles = detect_changed_roles(files)
assert "docker_base" in roles
def test_detect_role_not_on_disk_is_dropped(roles_dir: Path):
"""Changed role absent from roles_dir selects nothing (REQ-2)."""
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."""
def test_detect_playbook_change():
"""A playbook change maps to its included roles."""
files = ["ansible/playbooks/deploy-observability.yml"]
roles = detect_changed_roles(files, roles_dir)
# zitadel + crowdsec are mapped but absent from the fake roles dir.
assert roles == {"observability", "docker_base"}
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(roles_dir: Path):
"""ansible.cfg change triggers all testable roles only."""
roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir)
assert roles == set(TESTABLE_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_molecule_shared_path(roles_dir: Path):
"""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):
def test_detect_no_ansible_changes():
"""Non-Ansible files don't trigger any roles."""
roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir)
assert roles == set()
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"
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 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():
"""When no files changed, outputs message to stderr."""
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 "No changed files" in result.output
def test_main_print_targets(roles_dir: Path):
"""--print-targets outputs make targets for existing roles."""
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"],
):
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 "molecule-docker-base" in result.output
def test_main_print_roles(roles_dir: Path):
def test_main_print_roles():
"""--print-roles outputs role names."""
with patch(
"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 "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."""
with patch(
"devx.molecule.molecule_changed.get_changed_files",
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 "No molecule scenarios" in result.output
@@ -198,6 +119,7 @@ def test_get_changed_files_falls_back_to_master():
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"
@@ -215,12 +137,56 @@ def test_get_changed_files_empty():
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."""
with patch(
"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:
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
runner = CliRunner()
result = runner.invoke(main, ["--print-roles"])
assert result.exit_code == 0
mock.assert_called_once_with("origin/master")