mirror of
https://github.com/trailofbits/algo.git
synced 2025-09-16 08:53:00 +02:00
* 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>
315 lines
14 KiB
Python
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
|
|
|