"""Lint guards for the role meta layout. Failure modes covered: * Any role retains ``schema/main.yml``, ``users/main.yml`` or ``config/main.yml``. * Any source file references those legacy paths. * ``meta/main.yml`` carries ``run_after`` or ``lifecycle``. * ``meta/services.yml..lifecycle`` carries a value outside the linear axis in ``docs/contributing/design/role/services/lifecycle.md`` (``ALLOWED_LIFECYCLES``). * ``meta/services.yml..ports.{local,public}`` is a bare int instead of a category-keyed map. * Any host-bound port collides with another (single int + relay span set; port-collision AC). * Any ``meta/services.yml..ports`` value falls outside its ``PORT_BANDS..`` range. """ from __future__ import annotations import unittest from pathlib import Path from typing import ClassVar import yaml from utils.cache.files import iter_project_files, read_text from utils.cache.yaml import load_yaml_str from utils.roles.mapping import ( ROLE_FILE_META_MAIN, ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS, ) from . import PROJECT_ROOT ROLES_DIR = PROJECT_ROOT / "roles" ALLOWED_LIFECYCLES = { "planned", "pre-alpha", "alpha", "beta", "rc", "stable", "maintenance", "deprecated", "eol", } LEGACY_PORT_ALLOWLIST = { ("web-app-bigbluebutton", "bigbluebutton", "local", "http"): {48087}, } def _load_yaml(path: Path): if not path.is_file(): return None try: text = read_text(str(path)) except UnicodeDecodeError: return None if not text.strip(): return None return load_yaml_str(text) class TestNoLegacyRoleDirs(unittest.TestCase): def test_no_legacy_dirs(self): offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): if not role_dir.is_dir(): continue for legacy in ("config", "schema", "users"): legacy_path = role_dir / legacy if legacy_path.is_dir(): offenders.append(str(legacy_path.relative_to(PROJECT_ROOT))) if offenders: self.fail( "Legacy role directories present (must move under meta/):\n" + "\n".join(f" - {p}" for p in offenders) ) class TestNoLegacyPathReferences(unittest.TestCase): """Repository-wide grep guard for the old path strings.""" LEGACY_TOKENS = ( "schema/main.yml", "users/main.yml", "config/main.yml", "compose.services.", "compose.volumes.", ) SCAN_DIRS = ( "plugins", "utils", "cli", "roles", "scripts", "filter_plugins", "lookup_plugins", ) SKIP_FRAGMENTS = ( "/__pycache__/", "/.git/", "/node_modules/", "/.venv/", "/.cache/", ) EXEMPT_FILES: ClassVar[set[Path]] = { PROJECT_ROOT / "tasks" / "utils" / "migrate_meta_layout.py", } def test_no_legacy_path_strings(self): offenders: list[str] = [] scan_prefixes = tuple(f"{sub}/" for sub in self.SCAN_DIRS) scan_suffixes = (".py", ".yml", ".yaml", ".j2", ".jinja", ".jinja2") for path_str in iter_project_files(extensions=scan_suffixes): path = Path(path_str) try: rel_str = path.relative_to(PROJECT_ROOT).as_posix() except ValueError: continue if not rel_str.startswith(scan_prefixes): continue if any(frag in path_str for frag in self.SKIP_FRAGMENTS): continue if path in self.EXEMPT_FILES: continue try: text = read_text(path_str) except (UnicodeDecodeError, PermissionError): continue for token in self.LEGACY_TOKENS: if token in text: offenders.append(f"{rel_str} contains {token!r}") break if offenders: self.fail( "Legacy path tokens present:\n" + "\n".join(f" - {o}" for o in offenders) ) class TestMetaMainHasNoRunAfterOrLifecycle(unittest.TestCase): def test_no_run_after_or_lifecycle_in_meta_main(self): offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): if not role_dir.is_dir(): continue meta_main = role_dir / ROLE_FILE_META_MAIN data = _load_yaml(meta_main) if not isinstance(data, dict): continue galaxy_info = data.get("galaxy_info") if not isinstance(galaxy_info, dict): continue if "run_after" in galaxy_info: offenders.append( f"{meta_main.relative_to(PROJECT_ROOT)}: galaxy_info.run_after" ) if "lifecycle" in galaxy_info: offenders.append( f"{meta_main.relative_to(PROJECT_ROOT)}: galaxy_info.lifecycle" ) if offenders: self.fail( "run_after/lifecycle must live on the role's primary entity " "in meta/services.yml:\n" + "\n".join(f" - {o}" for o in offenders) ) class TestLifecycleAllowedValues(unittest.TestCase): def test_lifecycle_values_in_allowed_set(self): offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): if not role_dir.is_dir(): continue services = _load_yaml(role_dir / ROLE_FILE_META_SERVICES) if not isinstance(services, dict): continue for entity_name, entity in services.items(): if not isinstance(entity, dict): continue lifecycle = entity.get("lifecycle") if lifecycle is None: continue value = lifecycle.strip().lower() if isinstance(lifecycle, str) else "" if value not in ALLOWED_LIFECYCLES: offenders.append( f"{role_dir.name}/{entity_name}: lifecycle={lifecycle!r}" ) if offenders: self.fail( "lifecycle must be one of " f"{sorted(ALLOWED_LIFECYCLES)}:\n" + "\n".join(f" - {o}" for o in offenders) ) def test_ci_envelope_only_names_allowed_lifecycles(self): """A typo in ``INFINITO_LIFECYCLES`` silently drops every role of that stage from CI test-deploy discovery instead of failing, so the envelope and the allowed-value set must not be able to drift apart.""" from utils.roles.lifecycle import tested_lifecycles unknown = sorted(tested_lifecycles() - ALLOWED_LIFECYCLES) self.assertEqual( unknown, [], "default.env INFINITO_LIFECYCLES names stages that are not valid " f"lifecycle values: {unknown}. Roles tagged with a valid stage that " "is missing from the envelope are invisible to the deploy matrix.", ) class TestPortShape(unittest.TestCase): def test_local_and_public_ports_are_category_keyed_maps(self): offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): services = _load_yaml(role_dir / ROLE_FILE_META_SERVICES) if not isinstance(services, dict): continue for entity_name, entity in services.items(): if not isinstance(entity, dict): continue ports = entity.get("ports") if not isinstance(ports, dict): continue for scope_name in ("local", "public"): scope = ports.get(scope_name) if scope is None: continue if not isinstance(scope, dict): offenders.append( f"{role_dir.name}/{entity_name}.ports." f"{scope_name}: expected map, got " f"{type(scope).__name__}" ) if offenders: self.fail( "ports.{local,public} must be category-keyed maps:\n" + "\n".join(f" - {o}" for o in offenders) ) class TestHostBoundPortCollisions(unittest.TestCase): _SAME_ROLE_LEGACY_OVERLAPS: ClassVar[set[tuple[str, int]]] = { ("web-app-bigbluebutton", 48087), } @staticmethod def _provider_alternative_groups() -> list[set[str]]: """Role sets that are runtime-exclusive alternatives for one service key: the role that ``provides`` the key plus every role whose primary entity ``covers`` it (e.g. the mail providers — only the active ``MAIL_PROVIDER`` publishes the ports; the other renders without them). Derived from the self-declared registry, no role names here.""" from utils.roles.applications.services.registry import ( build_service_registry_from_roles_dir, ) registry = build_service_registry_from_roles_dir(ROLES_DIR) provider_of: dict[str, str] = {} coverers: dict[str, set[str]] = {} for key, entry in registry.items(): if "canonical" in entry: continue role = entry.get("role", "") provider_of[key] = role for covered in entry.get("covers", []) or []: coverers.setdefault(covered, set()).add(role) return [ {provider_of[key], *roles} for key, roles in coverers.items() if key in provider_of ] def test_no_host_bound_port_collisions(self): from utils.meta.scan import host_bound_port_set host_bound = host_bound_port_set() alternative_groups = self._provider_alternative_groups() clashes: list[str] = [] for port, owners in sorted(host_bound.items()): if len(owners) <= 1: continue roles_holding = {role for role, _e, _s, _c in owners} if ( len(roles_holding) == 1 and (next(iter(roles_holding)), port) in self._SAME_ROLE_LEGACY_OVERLAPS ): continue if any(roles_holding <= group for group in alternative_groups): continue pretty = ", ".join( f"{role}/{entity} {scope}.{cat}" for role, entity, scope, cat in owners ) clashes.append(f"port {port}: {pretty}") if clashes: self.fail( "Host-bound port collisions detected:\n" + "\n".join(f" - {c}" for c in clashes) + "\n\nFix: pick a free port via " + "`infinito meta ports suggest --scope " + "--category --count 1` " + "and update the role's meta/services.yml." ) class TestPortBandMembership(unittest.TestCase): def test_each_port_falls_within_its_band(self): from utils.meta.port_bands import lookup_band from utils.meta.scan import iter_port_assignments, iter_relay_ranges offenders: list[str] = [] for role, entity, scope, category, port in iter_port_assignments(): allowlist = LEGACY_PORT_ALLOWLIST.get( (role, entity, scope, category), set() ) if port in allowlist: continue band = lookup_band(scope, category) if band is None: offenders.append( f"{role}/{entity}.ports.{scope}.{category}={port}: " f"no PORT_BANDS entry" ) continue band_start, band_end = band if not (band_start <= port <= band_end): offenders.append( f"{role}/{entity}.ports.{scope}.{category}={port} is " f"outside PORT_BANDS.{scope}.{category}" f"=({band_start}-{band_end})" ) for role, entity, start, end in iter_relay_ranges(): band = lookup_band("public", "relay") if band is None: offenders.append( f"{role}/{entity}.ports.public.relay={start}-{end}: " f"no PORT_BANDS entry" ) continue if start > end: offenders.append( f"{role}/{entity}.ports.public.relay={start}-{end}: start >= end" ) band_start, band_end = band if start < band_start or end > band_end: offenders.append( f"{role}/{entity}.ports.public.relay={start}-{end} is " f"outside PORT_BANDS.public.relay" f"=({band_start}-{band_end})" ) if offenders: self.fail( "PORT_BANDS membership / shape violation:\n" + "\n".join(f" - {o}" for o in offenders) + "\n\nFix: pick an in-band port via " + "`infinito meta ports suggest --scope " + "--category --count 1`. " + "If the band itself is exhausted, extend " + "`PORT_BANDS...end` in " + "group_vars/all/08_networks.yml." ) class TestPortBandsDisjoint(unittest.TestCase): """PORT_BANDS within a single scope MUST be pairwise disjoint, otherwise a port could legitimately belong to two categories at once and the suggester / band-membership lint would silently route it to the wrong category. Cross-scope overlaps (e.g. local.http=8001-8099 and public.https=...) are intentional and not checked here.""" def test_no_overlapping_bands_in_same_scope(self): from utils.meta.port_bands import load_port_bands offenders: list[str] = [] for scope, scope_block in (load_port_bands() or {}).items(): if not isinstance(scope_block, dict): continue ranges: list[tuple[str, int, int]] = [] for category, entry in scope_block.items(): if not isinstance(entry, dict): continue start = entry.get("start") end = entry.get("end") if not isinstance(start, int) or not isinstance(end, int): continue if start > end: offenders.append( f"PORT_BANDS.{scope}.{category}: start ({start}) > end ({end})" ) continue ranges.append((category, start, end)) ranges.sort(key=lambda r: (r[1], r[2])) for i in range(len(ranges)): cat_a, start_a, end_a = ranges[i] for j in range(i + 1, len(ranges)): cat_b, start_b, end_b = ranges[j] if start_b > end_a: break overlap_start = max(start_a, start_b) overlap_end = min(end_a, end_b) offenders.append( f"PORT_BANDS.{scope}.{cat_a}={start_a}-{end_a} overlaps " f"PORT_BANDS.{scope}.{cat_b}={start_b}-{end_b} on " f"{overlap_start}-{overlap_end}" ) if offenders: self.fail( "PORT_BANDS overlap detected:\n" + "\n".join(f" - {o}" for o in offenders) + "\n\nFix: shrink one of the overlapping ranges in " + "group_vars/all/08_networks.yml so each port belongs to " + "at most one (scope, category) pair." ) def _legal_variant_roots() -> set[str]: """The application-payload roots a variant entry may override. Derived from the meta files themselves rather than restated: the loader maps ``meta/.yml`` onto ``applications..`` and the ``meta/addons/`` directory onto ``addons``. ``main`` (galaxy metadata) and ``variants`` (this file) are mechanism, not payload. """ mechanism = { Path(ROLE_FILE_META_MAIN).stem, Path(ROLE_FILE_META_VARIANTS).stem, } roots: set[str] = set() for meta_dir in sorted(ROLES_DIR.glob("*/meta")): if not meta_dir.is_dir(): continue roots.update(p.stem for p in meta_dir.glob("*.yml")) if any((meta_dir / "addons").glob("*.yml")): roots.add("addons") return roots - mechanism class TestVariantTopLevelKeys(unittest.TestCase): """A variant entry is baked into host_vars raw, so its top-level key IS the ``applications..`` path. A key no meta file produces lands where nothing reads it: the deploy stays green and the override is silently dropped. That is how `compose:` no-opped, and how `credentials:` survived the move to `secrets.credentials` (commit 8a812c20e).""" def test_variant_top_level_keys_are_payload_roots(self): legal = _legal_variant_roots() offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): if not role_dir.is_dir(): continue variants_path = role_dir / ROLE_FILE_META_VARIANTS if not variants_path.is_file(): continue try: text = read_text(str(variants_path)) except UnicodeDecodeError: continue try: docs = load_yaml_str(text) except yaml.YAMLError: continue if not isinstance(docs, list): continue for index, entry in enumerate(docs): if not isinstance(entry, dict): continue offenders.extend( f"{variants_path.relative_to(PROJECT_ROOT)} variant " f"{index}: `{key}:` is not an application-payload " f"root, so the override is silently discarded." for key in sorted(set(entry) - legal) ) if offenders: self.fail( f"meta/variants.yml top-level keys MUST be payload roots " f"({', '.join(sorted(legal))}):\n" + "\n".join(f" - {o}" for o in offenders) ) class TestRunAfterNotEmpty(unittest.TestCase): def test_no_empty_run_after_lists(self): offenders: list[str] = [] for role_dir in sorted(ROLES_DIR.iterdir()): services = _load_yaml(role_dir / ROLE_FILE_META_SERVICES) if not isinstance(services, dict): continue for entity_name, entity in services.items(): if not isinstance(entity, dict): continue run_after = entity.get("run_after") if isinstance(run_after, list) and not run_after: offenders.append(f"{role_dir.name}/{entity_name}") if offenders: self.fail( "run_after must be omitted (not empty list) when no ordering " "constraint exists:\n" + "\n".join(f" - {o}" for o in offenders) ) if __name__ == "__main__": unittest.main()