algo/tests/unit/test_comprehensive_boolean_scan.py
Dan Guido 4bb13a5ce8
Fix Ansible 12 double-templating and Jinja2 spacing issues (#14836)
* Fix Ansible 12 double-templating and Jinja2 spacing issues

This PR fixes critical deployment issues and improves code consistency for Ansible 12 compatibility.

## Fixed Issues

### 1. Double-templating bug (Issue #14835)
Fixed 7 instances of invalid double-templating that breaks deployments:
- Changed `{{ lookup('file', '{{ var }}') }}` to `{{ lookup('file', var) }}`
- Affects Azure, DigitalOcean, GCE, Linode, and IPsec configurations
- Added comprehensive test to prevent regression

### 2. Jinja2 spacing inconsistencies
Fixed 33+ spacing issues for better code quality:
- Removed spaces between Jinja2 blocks: `}} {%` → `}}{%`
- Fixed operator spacing: `int -1` → `int - 1`
- Fixed filter spacing: `|b64encode` → `| b64encode`
- Consolidated multiline expressions to single lines

### 3. Test suite improvements
Enhanced boolean type checking test to be more targeted:
- Excludes external dependencies and CloudFormation templates
- Only tests Algo's actual codebase
- Verified with mutation testing
- Added comprehensive documentation

## Testing
- All 87 unit tests pass
- 0 Jinja2 spacing issues remaining (verified by ansible-lint)
- Ansible syntax checks pass for all playbooks
- Mutation testing confirms tests catch real issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix Python linting issue

- Remove unnecessary f-string prefix where no placeholders are used
- Fixes ruff F541 error

* Fix line length linting issues

- Break long lines to stay within 120 character limit
- Extract variables for better readability
- Fixes ruff E501 errors

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-15 09:54:45 -04:00

315 lines
14 KiB
Python

#!/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:
potential_issues.append(
f"{yaml_file.name}:{i+1}: Comparing {var} to string in when condition"
)
elif 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