import re import unittest import yaml from pathlib import Path from collections import defaultdict # Directory containing group_vars/all/*.yml GROUPVARS_DIR = Path(__file__).resolve().parents[3] / "group_vars" / "all" JINJA_RE = re.compile(r"{{\s*([^}]+)\s*}}") # Matches variables like foo.bar, foo["bar"], foo['bar'] VAR_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|\[\"[^\"]+\"\]))*") def load_all_yaml(): """ Load and merge all YAML files under GROUPVARS_DIR, stripping 'defaults_' or 'default_' prefixes. """ result = {} for yaml_path in GROUPVARS_DIR.glob("*.yml"): with open(yaml_path, encoding="utf-8") as fh: data = yaml.safe_load(fh) or {} for k, v in data.items(): base = k for p in ("defaults_", "default_"): if base.startswith(p): base = base[len(p):] if base in result and isinstance(result[base], dict) and isinstance(v, dict): result[base].update(v) else: result[base] = v return result def find_jinja_refs(val): """ Find all unconditional Jinja variable paths inside {{…}} (including bracket-notation). Skip any expression containing ' if ' and ' else '. """ refs = [] if not isinstance(val, str): return refs for inner in JINJA_RE.findall(val): expr = inner.strip() if " if " in expr and " else " in expr: continue for m in VAR_PATTERN.finditer(expr): var = m.group(0) # normalize bracket notation foo["bar"] -> foo.bar var = re.sub(r"\[\"([^\"]+)\"\]", r".\1", var) var = re.sub(r"\['([^']+)'\]", r".\1", var) refs.append(var) return refs def build_edges(vars_dict): """ Walk the variables dict, return list of (source_key, referenced_var) edges. """ edges = [] def walk(node, path): if isinstance(node, dict): for k, v in node.items(): walk(v, path + [k]) elif isinstance(node, list): for i, e in enumerate(node): walk(e, path + [f"[{i}]"]) else: full_key = ".".join(path) for ref in find_jinja_refs(node): edges.append((full_key, ref)) walk(vars_dict, []) return edges class TestNoJinjaReferenceCycles(unittest.TestCase): def test_users_applications_cycle(self): all_vars = load_all_yaml() edges = build_edges(all_vars) user_to_app = any( src.startswith("users.") and ref == "applications" for src, ref in edges ) app_to_user = any( src.startswith("applications.") and ref.startswith("users.") for src, ref in edges ) if user_to_app and app_to_user: self.fail( "❌ Indirect Jinja-cycle detected:\n" " a) a `users.*` key references `applications`\n" " b) an `applications.*` key references `users.*`\n" "→ Combined this forms a cycle users → applications → users" ) def test_no_unconditional_recursive_loops(self): all_vars = load_all_yaml() edges = build_edges(all_vars) graph = defaultdict(set) for src, ref in edges: graph[src].add(ref) def dfs(node, visited, stack): if node in stack: return stack[stack.index(node):] + [node] if node in visited: return None visited.add(node) stack.append(node) for nxt in graph.get(node, []): cycle = dfs(nxt, visited, stack) if cycle: return cycle stack.pop() return None for node in list(graph): cycle = dfs(node, set(), []) if cycle: self.fail("❌ Jinja recursion cycle detected:\n " + " -> ".join(cycle)) if __name__ == "__main__": unittest.main()