computer-playbook/tests/integration/test_handler_names_static.py
Kevin Veen-Birkenbach 26b29debc0
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
2025-08-12 14:48:43 +02:00

115 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()