Add integration test to ensure no Jinja variables are used in handler names

This test scans roles/*/handlers/main.yml and fails if a handler's 'name' contains a Jinja variable ({{ ... }}).
Reason:
- Handler names must be static to ensure reliable 'notify' resolution.
- Dynamic names can break handler matching, cause undefined-variable errors, and produce unstable logs.
Recommendation:
- Keep handler names static and, if dynamic behavior is needed, use a static 'listen:' key.

https://chatgpt.com/share/689b37dc-e1e4-800f-bd56-00b43c7701f6
This commit is contained in:
Kevin Veen-Birkenbach 2025-08-12 14:47:45 +02:00
parent 0c4cd283c4
commit 26b29debc0
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Integration test: ensure no Jinja variables are used in handler *names*.
Why this policy?
- Handler identifiers should be stable strings. If you ever notify by handler
name (instead of a dedicated `listen:` key), a templated name can fail to
resolve or silently not match what `notify` referenced.
- Even when `listen:` is used (recommended), variable-laden names make logs and
tooling brittle and can trigger undefined-variable errors at parse/run time.
- Keeping handler names static improves reliability, debuggability, and
compatibility with analysis tools.
Allowed:
- You may still template other fields or use `listen:` for dynamic trigger
routing; just keep the handlers `name` static text.
This test scans: roles/*/handlers/main.yml
"""
import os
import glob
import re
import unittest
try:
import yaml # PyYAML
except ImportError as exc:
raise SystemExit(
"PyYAML is required to run this test. Install with: pip install pyyaml"
) from exc
JINJA_VAR_PATTERN = re.compile(r"{{.*?}}") # minimal check for any templating
def _iter_tasks(node):
"""
Yield all task-like dicts from a loaded YAML node, descending into common
task containers (`block`, `rescue`, `always`), just in case.
"""
if isinstance(node, dict):
# If this dict looks like a task (has 'name' or a module key), yield it.
if any(k in node for k in ("name", "action")):
yield node
# Dive into known task containers (handlers can include blocks too).
for key in ("block", "rescue", "always"):
if key in node and isinstance(node[key], list):
for item in node[key]:
yield from _iter_tasks(item)
elif isinstance(node, list):
for item in node:
yield from _iter_tasks(item)
class StaticHandlerNamesTest(unittest.TestCase):
"""
Ensures handler names are static strings (no Jinja variables like {{ ... }}).
"""
def test_no_templated_names_in_handlers(self):
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
pattern = os.path.join(project_root, "roles", "*", "handlers", "main.yml")
violations = []
for handler_path in sorted(glob.glob(pattern)):
# Load possibly multi-document YAML safely
try:
with open(handler_path, "r", encoding="utf-8") as f:
docs = list(yaml.safe_load_all(f))
except FileNotFoundError:
continue
except yaml.YAMLError as e:
violations.append(
f"{handler_path} -> YAML parse error: {e}"
)
continue
for doc in docs:
for task in _iter_tasks(doc):
name = task.get("name")
if not isinstance(name, str):
# ignore unnamed or non-string names
continue
if JINJA_VAR_PATTERN.search(name):
# Compose a clear, actionable message
listen = task.get("listen")
listen_hint = (
""
if listen
else " Consider using a static handler name and, if you need flexible triggers, add a static `listen:` key that your tasks `notify`."
)
violations.append(
f"{handler_path} -> Handler name contains variables: {name!r}\n"
"Reason: Handler names must be static. Using Jinja variables in the name "
"can break handler resolution (when notified by name), produces unstable logs, "
"and may cause undefined-variable errors. Keep the handler `name` constant."
f"{listen_hint}"
)
if violations:
self.fail(
"Templated handler names are not allowed.\n\n"
+ "\n\n".join(violations)
)
if __name__ == "__main__":
unittest.main()