From d5c14ad53c540892468a1c45061f86b4230aec2b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 15 Jul 2025 13:54:10 +0200 Subject: [PATCH] Added varaible defintion test draft --- .../integration/test_variable_definitions.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/integration/test_variable_definitions.py diff --git a/tests/integration/test_variable_definitions.py b/tests/integration/test_variable_definitions.py new file mode 100644 index 00000000..7cbd0759 --- /dev/null +++ b/tests/integration/test_variable_definitions.py @@ -0,0 +1,136 @@ +import unittest +import os +import yaml +import re +from glob import glob + +class TestVariableDefinitions(unittest.TestCase): + def setUp(self): + # Project root + self.project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), '../../') + ) + # Gather all definition files: include any .yml under vars/ and defaults/, plus group_vars/all + self.var_files = ( + glob(os.path.join(self.project_root, 'roles/*/vars/*.yml')) + + glob(os.path.join(self.project_root, 'roles/*/defaults/*.yml')) + + glob(os.path.join(self.project_root, 'group_vars/all/*.yml')) + ) + # Valid file extensions to scan for usages + self.scan_extensions = {'.yml', '.j2'} + + # Regexes + # Match simple Jinja variable usage: {{ var }} or with filters {{ var|filter }} + self.simple_var_pattern = re.compile(r"{{\s*([a-zA-Z_]\w*)\s*(?:\|[^}]*)?}}") + # Match Jinja2 set definitions: {% set var = ... %} + self.jinja_set_def = re.compile(r'{%\s*set\s+([a-zA-Z_]\w*)\s*=') + # Match Jinja2 for-loop variable definitions + self.jinja_for_def = re.compile(r'{%\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in') + # Match Ansible set_fact mapping start + self.ansible_set_fact = re.compile(r'^(?:\s*[-]\s*)?set_fact\s*:\s*$') + # Match ansible vars block start + self.ansible_vars_block = re.compile(r'^(?:\s*[-]\s*)?vars\s*:\s*$') + # Match ansible loop_control loop_var definition + self.ansible_loop_var = re.compile(r'^\s*loop_var\s*:\s*([a-zA-Z_]\w*)') + # Match keys under a mapping block + self.mapping_key = re.compile(r'^\s*([a-zA-Z_]\w*)\s*:\s*') + + # Build initial defined-vars set from all var definition files + self.defined = set() + for vf in self.var_files: + try: + with open(vf, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + self.defined.update(data.keys()) + except Exception: + pass + + def test_all_used_vars_are_defined(self): + undefined_uses = [] + + # Walk all .yml and .j2 files + for root, _, files in os.walk(self.project_root): + for fn in files: + ext = os.path.splitext(fn)[1] + if ext not in self.scan_extensions: + continue + + path = os.path.join(root, fn) + in_set_fact = False + set_fact_indent = None + in_vars_block = False + vars_block_indent = None + + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for lineno, line in enumerate(f, 1): + stripped = line.lstrip() + indent = len(line) - len(stripped) + # Detect start of a set_fact mapping + if self.ansible_set_fact.match(stripped): + in_set_fact = True + set_fact_indent = indent + continue + # Inside set_fact, collect keys + if in_set_fact: + if indent > set_fact_indent and self.mapping_key.match(stripped): + key = self.mapping_key.match(stripped).group(1) + self.defined.add(key) + continue + else: + in_set_fact = False + + # Detect start of a vars block under a task or play + if self.ansible_vars_block.match(stripped): + in_vars_block = True + vars_block_indent = indent + continue + # Inside vars block, collect keys and skip usage scanning + if in_vars_block: + if indent > vars_block_indent: + # any mapping key under vars is a definition + m = self.mapping_key.match(stripped) + if m: + key = m.group(1) + self.defined.add(key) + continue + else: + in_vars_block = False + + # Detect loop_control loop_var definitions + m_loop = self.ansible_loop_var.match(stripped) + if m_loop: + self.defined.add(m_loop.group(1)) + + # collect any {% set foo = ... %} definitions + for m in self.jinja_set_def.finditer(line): + self.defined.add(m.group(1)) + # collect any {% for var1, var2 in ... %} definitions + for m in self.jinja_for_def.finditer(line): + self.defined.add(m.group(1)) + if m.group(2): + self.defined.add(m.group(2)) + + # collect simple usages only + for m in self.simple_var_pattern.finditer(line): + var = m.group(1) + # skip known Jinja builtins and whitelisted names + if var in ('lookup', 'role_name', 'domains', 'item', 'host_type', 'inventory_hostname', 'role_path', 'playbook_dir', 'ansible_become_password', 'inventory_dir'): + continue + if var not in self.defined: + # check fallback names defaults_var or default_var + if (f"defaults_{var}" in self.defined or + f"default_{var}" in self.defined): + continue + undefined_uses.append( + f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined" + ) + + if undefined_uses: + self.fail( + "Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n" + + "\n".join(undefined_uses) + ) + +if __name__ == '__main__': + unittest.main()