import os import glob import re import unittest import yaml class RunOnceSchemaTest(unittest.TestCase): """ Ensure that any occurrence of 'run_once_' in roles/*/tasks/main.yml matches 'run_once_' + (role_name with '-' replaced by '_'), unless explicitly deactivated with: # run_once_: deactivated Only block-level 'when' conditions in main.yml are considered. """ def test_run_once_suffix_matches_role(self): project_root = os.path.abspath( os.path.join(os.path.dirname(__file__), '..', '..') ) violations = [] pattern = os.path.join(project_root, 'roles', '*', 'tasks', 'main.yml') for filepath in glob.glob(pattern): role_name = os.path.normpath(filepath).split(os.sep)[-3] expected_suffix = role_name.lower().replace('-', '_') with open(filepath, 'r', encoding='utf-8') as f: content = f.read() # Skip this role if deactivated deactivated_re = re.compile( rf"^\s*#\s*run_once_{re.escape(expected_suffix)}\s*:\s*deactivated\s*$", flags=re.IGNORECASE | re.MULTILINE, ) if deactivated_re.search(content): continue try: data = yaml.safe_load(content) except yaml.YAMLError as e: violations.append(f"{filepath}: YAML parse error: {e}") continue if not isinstance(data, list): continue for task in data: # Only check top-level blocks if isinstance(task, dict) and "block" in task: when_clause = task.get("when") if not when_clause: continue if isinstance(when_clause, list): run_once_vars = [w for w in when_clause if isinstance(w, str) and w.startswith("run_once_")] elif isinstance(when_clause, str): run_once_vars = [when_clause] if when_clause.startswith("run_once_") else [] else: run_once_vars = [] for var in run_once_vars: suffix = var[len("run_once_"):].split()[0] # strip any ' is not defined' if suffix != expected_suffix: violations.append( f"{filepath}: found block-level {var}, expected run_once_{expected_suffix}" ) if violations: self.fail("Invalid run_once_ suffixes found:\n" + "\n".join(violations)) if __name__ == '__main__': unittest.main()