Skip to content

Commit dfb3ca2

Browse files
committed
Add build-time test for ansible variable conflicts
This commit adds a new CTest that checks rendered ansible files for variable naming conflicts between registered variables and rule IDs. The problem occurs when ansible remediations register a variable with the same name as a rule ID. For example, if rule 'selinux_state' uses 'register: selinux_state', this creates a conflict with the 'selinux_state' boolean control variable used in when conditions like 'when: selinux_state | bool'. This causes the task to be skipped since the variable shadows the control variable, preventing proper execution. The test checks two locations in the build directory: 1. build/<product>/fixes/ansible/ - Individual rule remediations after template expansion 2. build/ansible/<product>-playbook-*.yml - Per-profile playbooks with fully rendered content The test runs for each product during the build and will fail if any registered variable name matches any rule ID in the system, ensuring that such conflicts are caught early in development. This is integrated into the cmake build system as a new test target 'ansible-variable-conflicts-<product>' that runs automatically during ctest execution and is labeled as a 'quick' test.
1 parent 3e27ad8 commit dfb3ca2

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed

cmake/SSGCommon.cmake

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,21 @@ macro(ssg_build_remediations PRODUCT)
301301
endif()
302302
endif()
303303
endif()
304+
# Test that registered variables in ansible fixes don't conflict with rule IDs
305+
# NOTE: This test requires SSG_ANSIBLE_PLAYBOOKS_ENABLED=ON and the per-profile
306+
# playbooks to be built (build/<product>/ansible/<product>-playbook-*.yml)
307+
# because those contain fully rendered ansible content without unexpanded
308+
# XCCDF variables. The individual fix files in build/<product>/fixes/ansible/
309+
# still have unexpanded variables like (xccdf-var ...) and are skipped by the test.
310+
# Before running this test, ensure playbooks are built:
311+
# ninja <product>-profile-playbooks (or just: ninja <product>)
312+
if(SSG_ANSIBLE_PLAYBOOKS_ENABLED)
313+
add_test(
314+
NAME "ansible-variable-conflicts-${PRODUCT}"
315+
COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${Python_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/tests/test_ansible_variable_conflicts.py" --build-dir "${CMAKE_BINARY_DIR}" --product "${PRODUCT}"
316+
)
317+
set_tests_properties("ansible-variable-conflicts-${PRODUCT}" PROPERTIES LABELS quick)
318+
endif()
304319
endif()
305320
endmacro()
306321

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/python3
2+
3+
"""
4+
Test that ansible registered variables don't conflict with rule IDs.
5+
6+
This test checks the rendered ansible remediation files (after template
7+
expansion) to ensure that no registered variable names match rule IDs.
8+
Such conflicts can cause issues where the registered variable name shadows
9+
the rule ID control variable that determines if the rule should be applied.
10+
11+
For example, if we have a rule 'selinux_state', the ansible remediation
12+
should not register a variable also called 'selinux_state', as this
13+
conflicts with the control variable typically used in conditions like
14+
'when: selinux_state | bool'.
15+
"""
16+
17+
import argparse
18+
import os
19+
import sys
20+
import yaml
21+
22+
23+
def get_all_rule_ids(build_dir):
24+
"""
25+
Extract all rule IDs from the rules directories in the build directory.
26+
27+
Args:
28+
build_dir: The build directory containing compiled product data
29+
30+
Returns:
31+
set: A set of all rule IDs found across all products
32+
"""
33+
rule_ids = set()
34+
35+
# Look for rules directories in each product build
36+
for product_dir in os.listdir(build_dir):
37+
rules_dir = os.path.join(build_dir, product_dir, "rules")
38+
if not os.path.isdir(rules_dir):
39+
continue
40+
41+
# Each .json file in rules/ represents a rule
42+
for filename in os.listdir(rules_dir):
43+
if filename.endswith('.json'):
44+
rule_id = os.path.splitext(filename)[0]
45+
rule_ids.add(rule_id)
46+
47+
return rule_ids
48+
49+
50+
def extract_registered_variables(tasks):
51+
"""
52+
Recursively extract all registered variable names from ansible tasks.
53+
54+
Args:
55+
tasks: A list of ansible tasks or a single task dict
56+
57+
Returns:
58+
set: A set of all registered variable names found
59+
"""
60+
registered_vars = set()
61+
62+
if not tasks:
63+
return registered_vars
64+
65+
# Handle both list of tasks and single task
66+
if isinstance(tasks, dict):
67+
tasks = [tasks]
68+
69+
if not isinstance(tasks, list):
70+
return registered_vars
71+
72+
for task in tasks:
73+
if not isinstance(task, dict):
74+
continue
75+
76+
# Check if task has a 'register' key
77+
if 'register' in task:
78+
var_name = task['register']
79+
if isinstance(var_name, str):
80+
registered_vars.add(var_name)
81+
82+
# Recursively check blocks
83+
if 'block' in task and isinstance(task['block'], list):
84+
registered_vars.update(extract_registered_variables(task['block']))
85+
86+
# Recursively check rescue blocks
87+
if 'rescue' in task and isinstance(task['rescue'], list):
88+
registered_vars.update(extract_registered_variables(task['rescue']))
89+
90+
# Recursively check always blocks
91+
if 'always' in task and isinstance(task['always'], list):
92+
registered_vars.update(extract_registered_variables(task['always']))
93+
94+
return registered_vars
95+
96+
97+
def check_ansible_file(ansible_file, rule_ids, product):
98+
"""
99+
Check a single ansible file for variable conflicts.
100+
101+
Args:
102+
ansible_file: Path to the ansible file to check
103+
rule_ids: Set of all rule IDs to check against
104+
product: The product ID
105+
106+
Returns:
107+
list: A list of dicts describing conflicts found in this file
108+
"""
109+
conflicts = []
110+
111+
try:
112+
with open(ansible_file, 'r') as f:
113+
content = f.read()
114+
115+
# Skip files that still have unexpanded Jinja or XCCDF variables
116+
if '(xccdf-var' in content or '{{{' in content:
117+
return conflicts
118+
119+
try:
120+
# Handle both single playbook format and task list format
121+
data = yaml.safe_load(content)
122+
if not data:
123+
return conflicts
124+
125+
# Extract tasks from playbook format or direct task list
126+
tasks = []
127+
if isinstance(data, list):
128+
# Could be a list of plays or a list of tasks
129+
if data and isinstance(data[0], dict):
130+
if 'hosts' in data[0]:
131+
# It's a playbook with plays
132+
for play in data:
133+
if 'tasks' in play:
134+
tasks.extend(play['tasks'])
135+
if 'pre_tasks' in play:
136+
tasks.extend(play['pre_tasks'])
137+
if 'post_tasks' in play:
138+
tasks.extend(play['post_tasks'])
139+
else:
140+
# It's a direct list of tasks
141+
tasks = data
142+
143+
# Extract registered variable names
144+
registered_vars = extract_registered_variables(tasks)
145+
146+
# Check if any registered variable matches a rule ID
147+
for var_name in registered_vars:
148+
if var_name in rule_ids:
149+
conflicts.append({
150+
'file': ansible_file,
151+
'variable': var_name,
152+
'conflicting_rule': var_name,
153+
'product': product
154+
})
155+
except yaml.YAMLError as e:
156+
# Skip files that can't be parsed
157+
print(f"Warning: Could not parse {ansible_file}: {e}", file=sys.stderr)
158+
return conflicts
159+
except IOError as e:
160+
# Skip files that can't be read
161+
print(f"Warning: Could not read {ansible_file}: {e}", file=sys.stderr)
162+
return conflicts
163+
164+
return conflicts
165+
166+
167+
def check_ansible_files_for_conflicts(build_dir, product, rule_ids):
168+
"""
169+
Check all rendered ansible files for a product for variable conflicts.
170+
171+
This checks both:
172+
1. Individual rule fixes in build/<product>/fixes/ansible/
173+
2. Per-profile playbooks in build/ansible/<product>-playbook-*.yml
174+
175+
Args:
176+
build_dir: The build directory
177+
product: The product ID to check
178+
rule_ids: Set of all rule IDs to check against
179+
180+
Returns:
181+
list: A list of dicts describing conflicts found
182+
"""
183+
conflicts = []
184+
185+
# Check the ansible fixes directory (individual rule remediations)
186+
ansible_fixes_dir = os.path.join(build_dir, product, "fixes", "ansible")
187+
if os.path.exists(ansible_fixes_dir):
188+
for filename in os.listdir(ansible_fixes_dir):
189+
if not filename.endswith(".yml"):
190+
continue
191+
192+
ansible_file = os.path.join(ansible_fixes_dir, filename)
193+
conflicts.extend(check_ansible_file(ansible_file, rule_ids, product))
194+
195+
# Check the per-profile playbooks directory (fully rendered playbooks)
196+
ansible_playbooks_dir = os.path.join(build_dir, "ansible")
197+
if os.path.exists(ansible_playbooks_dir):
198+
for filename in os.listdir(ansible_playbooks_dir):
199+
if not filename.startswith(f"{product}-playbook-") or not filename.endswith(".yml"):
200+
continue
201+
202+
ansible_file = os.path.join(ansible_playbooks_dir, filename)
203+
conflicts.extend(check_ansible_file(ansible_file, rule_ids, product))
204+
205+
return conflicts
206+
207+
208+
def main():
209+
"""Main function to run the test."""
210+
parser = argparse.ArgumentParser(
211+
description="Test that ansible registered variables don't conflict with rule IDs"
212+
)
213+
parser.add_argument(
214+
"--build-dir",
215+
required=True,
216+
help="Build directory containing compiled product data"
217+
)
218+
parser.add_argument(
219+
"--product",
220+
required=True,
221+
help="Product ID to check"
222+
)
223+
args = parser.parse_args()
224+
225+
# Check if per-profile playbooks exist (required for this test to work)
226+
ansible_playbooks_dir = os.path.join(args.build_dir, "ansible")
227+
if not os.path.exists(ansible_playbooks_dir):
228+
print(f"ERROR: Per-profile playbooks not found at {ansible_playbooks_dir}", file=sys.stderr)
229+
print("This test requires per-profile playbooks to be built.", file=sys.stderr)
230+
print("Please build them first with: ninja <product>-profile-playbooks", file=sys.stderr)
231+
print("Or ensure SSG_ANSIBLE_PLAYBOOKS_ENABLED is ON in cmake configuration.", file=sys.stderr)
232+
return 1
233+
234+
playbook_files = [f for f in os.listdir(ansible_playbooks_dir)
235+
if f.startswith(f"{args.product}-playbook-") and f.endswith(".yml")]
236+
if not playbook_files:
237+
print(f"ERROR: No playbook files found for product {args.product} in {ansible_playbooks_dir}", file=sys.stderr)
238+
print("Please build them first with: ninja <product>-profile-playbooks", file=sys.stderr)
239+
return 1
240+
241+
# Get all rule IDs from the build directory
242+
rule_ids = get_all_rule_ids(args.build_dir)
243+
244+
if not rule_ids:
245+
print("Warning: No rule IDs found in build directory", file=sys.stderr)
246+
return 0
247+
248+
# Check ansible files for the specified product
249+
conflicts = check_ansible_files_for_conflicts(args.build_dir, args.product, rule_ids)
250+
251+
if conflicts:
252+
print("ERROR: Found ansible registered variables that conflict with rule IDs:\n",
253+
file=sys.stderr)
254+
for conflict in conflicts:
255+
print(f" Product: {conflict['product']}", file=sys.stderr)
256+
print(f" File: {conflict['file']}", file=sys.stderr)
257+
print(f" Registered variable: '{conflict['variable']}'", file=sys.stderr)
258+
print(f" Conflicts with rule: '{conflict['conflicting_rule']}'", file=sys.stderr)
259+
print(f" Solution: Rename the registered variable in the source ansible remediation", file=sys.stderr)
260+
print("", file=sys.stderr)
261+
return 1
262+
263+
print(f"OK: No ansible variable conflicts found for product {args.product}")
264+
return 0
265+
266+
267+
if __name__ == "__main__":
268+
sys.exit(main())

0 commit comments

Comments
 (0)