Public Access
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-<role>) 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>
227 lines
8.5 KiB
Python
227 lines
8.5 KiB
Python
"""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.
|
|
"""
|
|
|
|
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):
|
|
"""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"}
|
|
|
|
|
|
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, 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(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_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."""
|
|
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 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=[]):
|
|
result = CliRunner().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."""
|
|
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)])
|
|
assert result.exit_code == 0
|
|
assert "molecule-docker-base" in result.output
|
|
|
|
|
|
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/restore/tasks/main.yml"],
|
|
):
|
|
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
|
assert result.exit_code == 0
|
|
assert "restore" in result.output
|
|
|
|
|
|
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"],
|
|
):
|
|
result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)])
|
|
assert result.exit_code == 0
|
|
assert "No molecule scenarios" in result.output
|
|
|
|
|
|
def test_get_changed_files_with_mock():
|
|
"""get_changed_files returns files from git diff."""
|
|
with patch("devx.molecule.molecule_changed._run_git", return_value="file1\nfile2\n"):
|
|
files = get_changed_files("origin/master")
|
|
assert files == ["file1", "file2"]
|
|
|
|
|
|
def test_get_changed_files_falls_back_to_master():
|
|
"""When base ref has no diff, falls back to master."""
|
|
calls: list[list[str]] = []
|
|
|
|
def mock_git(args):
|
|
calls.append(args)
|
|
if "origin/master...HEAD" in args[2]:
|
|
return ""
|
|
return "ansible/roles/docker_base/tasks/main.yml\n"
|
|
|
|
with patch("devx.molecule.molecule_changed._run_git", side_effect=mock_git):
|
|
files = get_changed_files("origin/master")
|
|
assert files == ["ansible/roles/docker_base/tasks/main.yml"]
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_get_changed_files_empty():
|
|
"""When no changes in either ref, returns empty list."""
|
|
with patch("devx.molecule.molecule_changed._run_git", return_value=""):
|
|
files = get_changed_files("origin/master")
|
|
assert files == []
|
|
|
|
|
|
def test_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/restore/tasks/main.yml"],
|
|
) as mock:
|
|
result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)])
|
|
assert result.exit_code == 0
|
|
mock.assert_called_once_with("origin/master")
|