import unittest import os import glob import yaml import re class TestIncludeImportTasksExistence(unittest.TestCase): def setUp(self): # Determine project root, roles directory and list of YAML files to scan tests_dir = os.path.dirname(__file__) # .../tests/integration project_root = os.path.abspath(os.path.join(tests_dir, os.pardir, os.pardir)) self.project_root = project_root self.roles_dir = os.path.join(project_root, "roles") self.files_to_scan = [] for filepath in glob.glob( os.path.join(project_root, "**", "*.yml"), recursive=True ): # Skip .git and tests directories if "/.git/" in filepath or "/tests/" in filepath: continue self.files_to_scan.append(filepath) def _collect_task_includes(self, data): """ Recursively collect all file references from include_tasks and import_tasks. Supports scalar syntax, block syntax ({ file: ... }), and block-list syntax. """ task_files = [] if isinstance(data, dict): for key, val in data.items(): if key in ("include_tasks", "import_tasks"): # Scalar syntax: include_tasks: tasks.yml if isinstance(val, str): task_files.append(val) # Block syntax: include_tasks: { file: tasks.yml } elif isinstance(val, dict) and "file" in val: task_files.append(val["file"]) # Block-list syntax: elif isinstance(val, list): for item in val: if isinstance(item, dict) and "file" in item: task_files.append(item["file"]) else: task_files.extend(self._collect_task_includes(val)) elif isinstance(data, list): for item in data: task_files.extend(self._collect_task_includes(item)) return task_files def test_include_import_tasks_exist(self): missing = [] for file_path in self.files_to_scan: with open(file_path) as f: try: documents = list(yaml.safe_load_all(f)) except yaml.YAMLError: self.fail(f"Failed to parse YAML in {file_path}") file_dir = os.path.dirname(file_path) # Determine the role context if under roles/ role_name = None if self.roles_dir in file_dir: parts = file_dir.split(os.sep) idx = parts.index("roles") if idx + 1 < len(parts): role_name = parts[idx + 1] role_path_dir = os.path.join(self.roles_dir, role_name) for doc in documents: for task_ref in self._collect_task_includes(doc): # Handle special Jinja2 vars pattern_ref = task_ref if "{{ role_path }}" in pattern_ref and role_name: pattern_ref = pattern_ref.replace( "{{ role_path }}", role_path_dir ) if "{{ playbook_dir }}" in pattern_ref: pattern_ref = pattern_ref.replace( "{{ playbook_dir }}", self.project_root ) # Replace other Jinja2 expressions with wildcard pattern_ref = re.sub(r"\{\{.*?\}\}", "*", pattern_ref) # If no extension, assume .yml if not os.path.splitext(pattern_ref)[1]: pattern_ref += ".yml" # Prepare search globs local_glob = os.path.join(file_dir, pattern_ref) global_glob = os.path.join(self.project_root, pattern_ref) tasks_dir_glob = os.path.join( self.project_root, "tasks", pattern_ref ) matches = [] matches += [p for p in glob.glob(local_glob) if os.path.isfile(p)] matches += [p for p in glob.glob(global_glob) if os.path.isfile(p)] matches += [ p for p in glob.glob(tasks_dir_glob) if os.path.isfile(p) ] if not matches: missing.append((file_path, task_ref)) if missing: messages = [ f"File '{fp}' references missing task file '{tr}'" for fp, tr in missing ] self.fail("\n".join(messages)) if __name__ == "__main__": unittest.main()