#!/usr/bin/env python3 """ Test suite to prevent Ansible 12+ boolean type errors in Algo VPN codebase. Background: ----------- Ansible 12.0.0 introduced strict boolean type checking that breaks deployments when string values like "true" or "false" are used in conditionals. This causes errors like: "Conditional result (True) was derived from value of type 'str'" What This Test Protects Against: --------------------------------- 1. String literals "true"/"false" being used instead of actual booleans 2. Bare true/false in Jinja2 else clauses (should be {{ true }}/{{ false }}) 3. String comparisons in when: conditions (e.g., var == "true") 4. Variables being set to string booleans instead of actual booleans Test Scope: ----------- - Only tests Algo's own code (roles/, playbooks/, etc.) - Excludes external dependencies (.env/, ansible_collections/) - Excludes CloudFormation templates which require string booleans - Excludes test files which may use different patterns Mutation Testing Verified: -------------------------- All tests have been verified to catch their target issues through mutation testing: - Introducing bare 'false' in else clause → caught by test_no_bare_false_in_jinja_else - Using string boolean in facts.yml → caught by test_verify_our_fixes_are_correct - Adding string boolean assignments → caught by test_no_other_problematic_patterns Related Issues: --------------- - PR #14834: Fixed initial boolean type issues for Ansible 12 - Issue #14835: Fixed double-templating issues exposed by Ansible 12 """ import re from pathlib import Path class TestComprehensiveBooleanScan: """Scan entire codebase for potential string boolean issues.""" def get_yaml_files(self): """Get all YAML files in the Algo project, excluding external dependencies.""" root = Path(__file__).parent.parent.parent yaml_files = [] # Define directories to scan (Algo's actual code) algo_dirs = [ "roles", "playbooks", "library", "files/cloud-init", # Include cloud-init templates but not CloudFormation ] # Add root-level YAML files yaml_files.extend(root.glob("*.yml")) yaml_files.extend(root.glob("*.yaml")) # Add YAML files from Algo directories for dir_name in algo_dirs: dir_path = root / dir_name if dir_path.exists(): yaml_files.extend(dir_path.glob("**/*.yml")) yaml_files.extend(dir_path.glob("**/*.yaml")) # Exclude patterns excluded = [ ".venv", # Virtual environment ".env", # Another virtual environment pattern "venv", # Yet another virtual environment "test", # Test files (but keep our own tests) "molecule", # Molecule test files "site-packages", # Python packages "ansible_collections", # External Ansible collections "stack.yaml", # CloudFormation templates (use string booleans by design) "stack.yml", # CloudFormation templates ".git", # Git directory "__pycache__", # Python cache ] # Filter out excluded paths and CloudFormation templates filtered = [] for f in yaml_files: path_str = str(f) # Skip if path contains any excluded pattern if any(exc in path_str for exc in excluded): continue # Skip CloudFormation templates in files/ directories if "/files/" in path_str and f.name in ["stack.yaml", "stack.yml"]: continue filtered.append(f) return filtered def test_no_string_true_false_in_set_fact(self): """Scan all YAML files for set_fact with string 'true'/'false'.""" issues = [] pattern = re.compile(r'set_fact:.*?\n.*?:\s*".*\}(true|false)\{.*"', re.MULTILINE | re.DOTALL) for yaml_file in self.get_yaml_files(): with open(yaml_file) as f: content = f.read() matches = pattern.findall(content) if matches: issues.append(f"{yaml_file.name}: Found string boolean in set_fact: {matches}") assert not issues, "Found string booleans in set_fact:\n" + "\n".join(issues) def test_no_bare_false_in_jinja_else(self): """Check for bare 'false' after else in Jinja expressions.""" issues = [] # Pattern for {%- else %}false{% (should be {{ false }}) pattern = re.compile(r"\{%-?\s*else\s*%\}(true|false)\{%") for yaml_file in self.get_yaml_files(): with open(yaml_file) as f: content = f.read() matches = pattern.findall(content) if matches: issues.append(f"{yaml_file.name}: Found bare '{matches[0]}' after else") assert not issues, "Found bare true/false in else clauses:\n" + "\n".join(issues) def test_when_conditions_use_booleans(self): """Verify 'when:' conditions that use our variables.""" boolean_vars = [ "ipv6_support", "algo_dns_adblocking", "algo_ssh_tunneling", "algo_ondemand_cellular", "algo_ondemand_wifi", "algo_store_pki", ] potential_issues = [] for yaml_file in self.get_yaml_files(): with open(yaml_file) as f: lines = f.readlines() for i, line in enumerate(lines): if "when:" in line: for var in boolean_vars: if var in line: # Check if it's a simple condition (good) or comparing to string (bad) if ( f'{var} == "true"' in line or f'{var} == "false"' in line or f'{var} != "true"' in line or f'{var} != "false"' in line ): potential_issues.append( f"{yaml_file.name}:{i + 1}: Comparing {var} to string in when condition" ) assert not potential_issues, "Found string comparisons in when conditions:\n" + "\n".join(potential_issues) def test_template_files_boolean_usage(self): """Check Jinja2 template files for boolean usage.""" root = Path(__file__).parent.parent.parent template_files = list(root.glob("**/*.j2")) issues = [] for template_file in template_files: if ".venv" in str(template_file): continue with open(template_file) as f: content = f.read() # Check for conditionals using our boolean variables if "ipv6_support" in content: # Look for string comparisons if 'ipv6_support == "true"' in content or 'ipv6_support == "false"' in content: issues.append(f"{template_file.name}: Comparing ipv6_support to string") # Check it's used correctly in if statements if re.search(r'{%\s*if\s+ipv6_support\s*==\s*["\']true["\']', content): issues.append(f"{template_file.name}: String comparison with ipv6_support") assert not issues, "Found issues in template files:\n" + "\n".join(issues) def test_all_when_conditions_would_work(self): """Test that all when: conditions in the codebase would work with boolean types.""" root = Path(__file__).parent.parent.parent test_files = [ root / "roles/common/tasks/iptables.yml", root / "server.yml", root / "users.yml", root / "roles/dns/tasks/main.yml", ] for test_file in test_files: if not test_file.exists(): continue with open(test_file) as f: content = f.read() # Find all when: conditions when_lines = re.findall(r"when:\s*([^\n]+)", content) for when_line in when_lines: # Check if it's using one of our boolean variables if any(var in when_line for var in ["ipv6_support", "algo_dns_adblocking", "algo_ssh_tunneling"]): # Ensure it's not comparing to strings assert '"true"' not in when_line, f"String comparison in {test_file.name}: {when_line}" assert '"false"' not in when_line, f"String comparison in {test_file.name}: {when_line}" assert "'true'" not in when_line, f"String comparison in {test_file.name}: {when_line}" assert "'false'" not in when_line, f"String comparison in {test_file.name}: {when_line}" def test_no_other_problematic_patterns(self): """Look for patterns that would cause Ansible 12 boolean type issues in Algo code.""" # These patterns would break Ansible 12's strict boolean checking problematic_patterns = [ (r':\s*["\']true["\']$', "Assigning string 'true' to variable"), (r':\s*["\']false["\']$', "Assigning string 'false' to variable"), (r'default\(["\']true["\']\)', "Using string 'true' as default"), (r'default\(["\']false["\']\)', "Using string 'false' as default"), ] # Known safe exceptions in Algo safe_patterns = [ "booleans_map", # This maps string inputs to booleans "test_", # Test files may use different patterns "molecule", # Molecule tests "ANSIBLE_", # Environment variables are strings "validate_certs", # Some modules accept string booleans "Default:", # CloudFormation parameter defaults ] issues = [] for yaml_file in self.get_yaml_files(): # Skip files that aren't Ansible playbooks/tasks/vars parts_to_check = ["tasks", "vars", "defaults", "handlers", "meta", "playbooks"] main_files = ["main.yml", "users.yml", "server.yml", "input.yml"] if not any(part in str(yaml_file) for part in parts_to_check) and yaml_file.name not in main_files: continue with open(yaml_file) as f: lines = f.readlines() for i, line in enumerate(lines): # Skip comments and empty lines stripped_line = line.strip() if not stripped_line or stripped_line.startswith("#"): continue for pattern, description in problematic_patterns: if re.search(pattern, line): # Check if it's a known safe pattern if not any(safe in line for safe in safe_patterns): # This is a real issue that would break Ansible 12 rel_path = yaml_file.relative_to(Path(__file__).parent.parent.parent) issues.append(f"{rel_path}:{i + 1}: {description} - {stripped_line}") # All Algo code should be fixed assert not issues, "Found boolean type issues that would break Ansible 12:\n" + "\n".join(issues[:10]) def test_verify_our_fixes_are_correct(self): """Verify our specific fixes are in place and correct.""" # Check facts.yml facts_file = Path(__file__).parent.parent.parent / "roles/common/tasks/facts.yml" with open(facts_file) as f: content = f.read() # Should use 'is defined', not string literals assert "is defined" in content, "facts.yml should use 'is defined'" old_pattern = "ipv6_support: \"{% if ansible_default_ipv6['gateway'] is defined %}" old_pattern += 'true{% else %}false{% endif %}"' assert old_pattern not in content, "facts.yml still has the old string boolean pattern" # Check input.yml input_file = Path(__file__).parent.parent.parent / "input.yml" with open(input_file) as f: content = f.read() # Count occurrences of the fix assert content.count("{{ false }}") >= 5, "input.yml should have at least 5 instances of {{ false }}" assert "{%- else %}false{% endif %}" not in content, "input.yml still has bare 'false'" def test_templates_handle_booleans_correctly(self): """Test that template files handle boolean variables correctly.""" templates_to_check = [ ("roles/wireguard/templates/server.conf.j2", "ipv6_support"), ("roles/strongswan/templates/ipsec.conf.j2", "ipv6_support"), ("roles/dns/templates/dnscrypt-proxy.toml.j2", "ipv6_support"), ] for template_path, var_name in templates_to_check: template_file = Path(__file__).parent.parent.parent / template_path if not template_file.exists(): continue with open(template_file) as f: content = f.read() if var_name in content: # Verify it's used in conditionals, not compared to strings assert f'{var_name} == "true"' not in content, f"{template_path} compares {var_name} to string 'true'" assert f'{var_name} == "false"' not in content, f"{template_path} compares {var_name} to string 'false'" # It should be used directly in if statements or with | bool filter if f"if {var_name}" in content or f"{var_name} |" in content: pass # Good - using it as a boolean