Files
computer-playbook/tests/integration/roles/meta/test_mapping.py
kevinveenbirkenbachandClaude Opus 4.7 94018a1235 fix(tests/integration/roles/meta): skip dunder dirs in role discovery
`roles/__pycache__/` gets created whenever Python imports the `roles`
package (it has an `__init__.py`), then iterdir() picks it up as a role
and the mandatory-files check fails for the synthetic "tooling" role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:36:05 +02:00

201 lines
7.2 KiB
Python

"""Generalist lint guard for the role-mapping SPOT.
For every role under ``roles/`` and every entry in
:data:`utils.roles.mapping.ROLE_FILES`, this test cross-checks the
schema's ``mandatory`` / ``allowed`` policy against what the role
actually ships on disk:
* **Forbidden file present.** The role's type set is not permitted to
carry the file. Either declare a marker that legitimises the file
(e.g. add ``application_id`` to ``vars/main.yml``) or remove the
file.
* **Mandatory file missing.** At least one of the role's types
requires the file. Create the file or change the role's type by
removing the marker that pulls the type in.
* **Forbidden entry present.** The dotted path is set in the file but
no type the role belongs to permits it.
* **Mandatory entry missing.** A type the role belongs to requires
the dotted path, but it is absent or empty in the file.
A role MAY carry several types simultaneously; the role's effective
policy is the UNION over its types: a file/entry is required when ANY
type requires it, allowed when ANY type allows it, and forbidden only
when no type allows it.
Policy resolution per (file, role_type):
1. an explicit type entry overrides everything else;
2. otherwise the :data:`utils.roles.mapping.ROLE_TYPE_ALL` wildcard
acts as the fallback;
3. otherwise the file/entry is implicitly forbidden for that type.
"""
from __future__ import annotations
import unittest
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILES, ROLE_TYPE_ALL
from utils.roles.type import get_role_types
from . import PROJECT_ROOT
_ROLES_DIR = PROJECT_ROOT / "roles"
def _entry_for_type(file_entry: dict, role_type: str) -> dict | None:
"""Return the type-scoping entry that applies to *role_type*.
Concrete-type entries override the wildcard fallback; when neither
matches the file is implicitly forbidden for that type.
"""
types = file_entry.get("types") or []
wildcard: dict | None = None
for entry in types:
if not isinstance(entry, dict):
continue
t = entry.get("type")
if t == role_type:
return entry
if t == ROLE_TYPE_ALL and wildcard is None:
wildcard = entry
return wildcard
def _aggregate_policy(
file_entry: dict, role_types: frozenset[str]
) -> tuple[bool, bool, dict[str, dict[str, bool]]]:
"""Return ``(file_allowed, file_mandatory, entries)`` aggregated
across every role type the role belongs to.
*entries* maps each declared dotted path to ``{"allowed": bool,
"mandatory": bool}``. A path counts as allowed when any type that
lists it permits it, and as mandatory when any type that lists it
requires it.
"""
file_allowed = False
file_mandatory = False
entries: dict[str, dict[str, bool]] = {}
for role_type in role_types:
type_entry = _entry_for_type(file_entry, role_type)
if type_entry is None:
continue
if bool(type_entry.get("allowed", True)):
file_allowed = True
if bool(type_entry.get("mandatory", False)):
file_mandatory = True
for sub in type_entry.get("entries") or []:
if not isinstance(sub, dict):
continue
path = sub.get("path")
if not isinstance(path, str) or not path:
continue
slot = entries.setdefault(path, {"allowed": False, "mandatory": False})
if bool(sub.get("allowed", True)):
slot["allowed"] = True
if bool(sub.get("mandatory", False)):
slot["mandatory"] = True
return file_allowed, file_mandatory, entries
def _resolve_dotted(data: object, dotted: str) -> object:
"""Walk *dotted* (``a.b.c``) into *data*. Return ``None`` whenever a
segment is missing or *data* turns into a non-mapping mid-walk.
"""
cursor: object = data
for segment in dotted.split("."):
if not isinstance(cursor, dict):
return None
cursor = cursor.get(segment)
return cursor
def _value_present(value: object) -> bool:
"""Return ``True`` when *value* counts as a real, non-empty entry.
``None``, empty strings, whitespace-only strings, empty lists, and
empty mappings all count as absent so a key declared without a
value does not satisfy a ``mandatory: True`` requirement.
"""
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple, dict, set)):
return bool(value)
return True
class TestRoleMapping(unittest.TestCase):
def test_role_files_match_mapping_spot(self):
forbidden_files: list[str] = []
missing_files: list[str] = []
forbidden_entries: list[str] = []
missing_entries: list[str] = []
for role_dir in sorted(
p
for p in _ROLES_DIR.iterdir()
if p.is_dir() and not p.name.startswith("__")
):
role_types = get_role_types(role_dir)
type_label = ", ".join(sorted(role_types))
for file_rel_path, file_entry in ROLE_FILES.items():
rel_meta = role_dir.relative_to(PROJECT_ROOT) / file_rel_path
meta_file = role_dir / file_rel_path
present = meta_file.is_file()
file_allowed, file_mandatory, entry_rules = _aggregate_policy(
file_entry, role_types
)
if present and not file_allowed:
forbidden_files.append(f"{rel_meta} (role types: {type_label})")
if not present and file_mandatory:
missing_files.append(f"{rel_meta} (role types: {type_label})")
if not present or not entry_rules:
continue
data = load_yaml_any(str(meta_file), default_if_missing={})
if not isinstance(data, dict):
continue
for path, rules in sorted(entry_rules.items()):
value = _resolve_dotted(data, path)
has_value = _value_present(value)
if rules["mandatory"] and not has_value:
missing_entries.append(
f"{rel_meta}:{path} (role types: {type_label})"
)
if not rules["allowed"] and has_value:
forbidden_entries.append(
f"{rel_meta}:{path} (role types: {type_label})"
)
sections: list[str] = []
def _add(title: str, items: list[str]) -> None:
if not items:
return
sections.append(f"{title} ({len(items)}):")
sections.extend(f" - {item}" for item in items)
_add("Forbidden file(s) present", forbidden_files)
_add("Mandatory file(s) missing", missing_files)
_add("Forbidden entr(y|ies) present", forbidden_entries)
_add("Mandatory entr(y|ies) missing", missing_entries)
if sections:
self.fail(
"Role mapping violations vs. utils/roles/mapping.py:\n"
+ "\n".join(sections)
)
if __name__ == "__main__": # pragma: no cover
unittest.main()