From 2a94860e122230f1d577c47395db51d2430a7eb8 Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Wed, 23 Sep 2026 10:08:41 +0200 Subject: [PATCH] fix: fail-open molecule selection and honest fast-path contract S09/REQ-9: detect_changed_roles silently skipped unmapped ansible paths (restore.yml, build-image.yml, group_vars, playbook _tasks), emitted nonexistent make targets for absent roles (sso_config), and fast_molecule documented a converge+verify contract that diverged from the executed full molecule test sequence. - Map all infra playbooks; unmapped playbooks/ and group_vars/ now fail open to all testable roles. - Role selection filtered to dirs present on disk with molecule/ dirs. - Make targets derived by convention (molecule-) instead of a stale hardcoded map. - fast_molecule emits the exact commands CI runs and documents the real full-test sequence. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 2 +- docs/specs/DEVX-177.md | 76 +++++++++ src/devx/ci/fast_molecule.py | 33 ++-- src/devx/molecule/molecule_changed.py | 95 +++++++---- tests/unit/test_fast_molecule.py | 7 +- tests/unit/test_molecule_changed.py | 216 +++++++++++++++----------- 6 files changed, 290 insertions(+), 139 deletions(-) create mode 100644 docs/specs/DEVX-177.md diff --git a/AGENTS.md b/AGENTS.md index 1086e35..4d3d4c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` — converge+verify only for changed roles, single platform +- `devx.ci.fast_molecule` — full `molecule test` for changed roles only (scoped, single platform) **Nightly (infra only):** - Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests diff --git a/docs/specs/DEVX-177.md b/docs/specs/DEVX-177.md new file mode 100644 index 0000000..e031487 --- /dev/null +++ b/docs/specs/DEVX-177.md @@ -0,0 +1,76 @@ +# 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 `/` 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 `); +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 + ` 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. diff --git a/src/devx/ci/fast_molecule.py b/src/devx/ci/fast_molecule.py index 19817ff..3ad73e8 100644 --- a/src/devx/ci/fast_molecule.py +++ b/src/devx/ci/fast_molecule.py @@ -5,14 +5,19 @@ Reuses ``devx.molecule.molecule_changed`` for role detection (which handles playbook→role mapping and shared infrastructure paths). -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). +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). Usage: python -m devx.ci.fast_molecule --base origin/master --head HEAD -Outputs the list of changed roles and the molecule commands to run. +Outputs the list of changed roles and the molecule commands CI runs. In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space- separated) and ``fast-molecule-needed`` (true/false) for downstream steps. """ @@ -48,19 +53,19 @@ def build_molecule_commands( roles_dir: str = "ansible/roles", platform: str = "ubuntu-2604", ) -> list[str]: - """Build molecule test commands for changed roles. + """Build the molecule commands CI executes for changed roles. - For each role, runs each scenario with converge + verify only - (skip create/destroy between scenarios, skip idempotence). + Emits exactly what ``scripts/run_molecule_scenario.py`` runs: + ``molecule test -s `` — 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. """ commands: list[str] = [] for role in sorted(roles): - 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) + for scenario in get_molecule_scenarios(role, roles_dir): + commands.append(f"molecule test -s {scenario}") return commands @@ -93,7 +98,7 @@ def cli( write_github_output("fast-molecule-roles", "") return - roles = detect_changed_roles(files) + roles = detect_changed_roles(files, roles_dir) if not roles: click.echo("[fast-molecule] No Ansible roles changed.") if github_output: diff --git a/src/devx/molecule/molecule_changed.py b/src/devx/molecule/molecule_changed.py index 66b4c6d..663d78f 100644 --- a/src/devx/molecule/molecule_changed.py +++ b/src/devx/molecule/molecule_changed.py @@ -17,8 +17,13 @@ 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. +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. """ from __future__ import annotations @@ -30,26 +35,28 @@ 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", -} + +def role_to_target(role: str) -> str: + """Make target for a role: docker_base -> molecule-docker-base.""" + return f"molecule-{role.replace('_', '-')}" + # 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", "sso_config"], - "ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"], + "ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening"], "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. @@ -57,6 +64,7 @@ SHARED_PATHS = ( "ansible/ansible.cfg", "ansible/requirements.yml", "ansible/molecule/", + "ansible/group_vars/", ) # Minimum path parts for a role file: ansible/roles/ (3 parts). @@ -85,8 +93,30 @@ def get_changed_files(base: str) -> list[str]: return [] -def detect_changed_roles(changed_files: list[str]) -> set[str]: - """Detect which roles have changed files.""" +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) roles: set[str] = set() for filepath in changed_files: @@ -99,23 +129,23 @@ def detect_changed_roles(changed_files: list[str]) -> set[str]: # 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(ROLE_TARGET_MAP.keys()) + return set(all_roles) - return roles + # 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()} 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 + """Convert role names to make targets (conventional molecule-).""" + return [role_to_target(role) for role in sorted(roles)] @click.command() @@ -134,14 +164,19 @@ def roles_to_targets(roles: set[str]) -> list[str]: is_flag=True, help="Print role names (default if no --print-targets).", ) -def main(base: str, print_targets: bool, print_roles: bool) -> None: +@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: """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 = detect_changed_roles(changed_files, roles_dir) if not roles: click.echo("No molecule scenarios affected by changes.", err=True) return diff --git a/tests/unit/test_fast_molecule.py b/tests/unit/test_fast_molecule.py index cbaed11..3b99723 100644 --- a/tests/unit/test_fast_molecule.py +++ b/tests/unit/test_fast_molecule.py @@ -40,9 +40,10 @@ class TestBuildMoleculeCommands: commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir)) assert len(commands) == 2 - 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) + # REQ-3: emitted commands are exactly what run_molecule_scenario + # executes — full `molecule test -s `, 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) def test_empty_when_no_scenarios(self, tmp_path: Path) -> None: commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles")) diff --git a/tests/unit/test_molecule_changed.py b/tests/unit/test_molecule_changed.py index eea39d6..6a69442 100644 --- a/tests/unit/test_molecule_changed.py +++ b/tests/unit/test_molecule_changed.py @@ -1,107 +1,186 @@ """Unit tests for devx.molecule.molecule_changed. -Verifies that the script correctly detects changed roles and maps -them to make targets. +Verifies that the script correctly detects changed roles, fails open on +unmapped ansible paths, and only emits roles that exist on disk. """ 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") -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// maps to that role.""" - files = ["ansible/roles/docker_base/tasks/main.yml"] - roles = detect_changed_roles(files) - assert "docker_base" in roles + roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir) + assert roles == {"docker_base"} -def test_detect_playbook_change(): - """A playbook change maps to its included 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.""" files = ["ansible/playbooks/deploy-observability.yml"] - roles = detect_changed_roles(files) - assert "observability" in roles - assert "docker_base" in roles - assert "zitadel" in roles + roles = detect_changed_roles(files, roles_dir) + # zitadel + crowdsec are mapped but absent from the fake roles dir. + assert roles == {"observability", "docker_base"} -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_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_no_ansible_changes(): +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): """Non-Ansible files don't trigger any roles.""" - files = ["scripts/molecule_changed.py", "Makefile"] - roles = detect_changed_roles(files) - assert len(roles) == 0 + 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" 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"] + assert targets == ["molecule-docker-base", "molecule-zitadel"] 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"]) + result = CliRunner().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.""" +def test_main_print_targets(roles_dir: Path): + """--print-targets outputs make targets for existing roles.""" 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"]) + result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 assert "molecule-docker-base" in result.output -def test_main_print_roles(): +def test_main_print_roles(roles_dir: Path): """--print-roles outputs role names.""" with patch( "devx.molecule.molecule_changed.get_changed_files", - return_value=["ansible/roles/zitadel/tasks/main.yml"], + return_value=["ansible/roles/restore/tasks/main.yml"], ): - runner = CliRunner() - result = runner.invoke(main, ["--print-roles"]) + result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 - assert "zitadel" in result.output + assert "restore" in result.output -def test_main_no_ansible_changes(): +def test_main_no_ansible_changes(roles_dir: Path): """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"]) + result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 assert "No molecule scenarios" in result.output @@ -119,7 +198,6 @@ 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" @@ -137,56 +215,12 @@ def test_get_changed_files_empty(): 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(): +def test_main_default_base(roles_dir: Path): """main() with no --base uses origin/master.""" with patch( "devx.molecule.molecule_changed.get_changed_files", - return_value=["ansible/roles/zitadel/tasks/main.yml"], + return_value=["ansible/roles/restore/tasks/main.yml"], ) as mock: - runner = CliRunner() - result = runner.invoke(main, ["--print-roles"]) + result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 mock.assert_called_once_with("origin/master") -- 2.54.0