mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-09-14 06:17:14 +02:00
Fix false negatives in integration test for unused vars
Updated tests/integration/test_vars_usage_in_yaml.py: - Variables immediately followed by '(' are now treated as function calls, not as set variables. This prevents false errors. - Fixed detection of redirect_domain_mappings so it is no longer flagged as unused. See: https://chatgpt.com/share/68c3542d-f44c-800f-a483-b3e43739f315
This commit is contained in:
@@ -11,10 +11,6 @@
|
||||
- name: "For '{{ application_id }}': load docker, db and proxy"
|
||||
include_role:
|
||||
name: sys-stk-full-stateful
|
||||
vars:
|
||||
# Forward flag into compose templating
|
||||
cmp_extra_facts:
|
||||
akaunting_setup_enabled: "{{ akaunting_setup_enabled }}"
|
||||
|
||||
- name: "Akaunting | Create first-run marker to disable future setup"
|
||||
ansible.builtin.file:
|
||||
|
@@ -2,15 +2,5 @@
|
||||
|
||||
This Ansible role configures Nginx to perform 301 redirects from one domain to another. It handles SSL certificate retrieval for the source domains and sets up the Nginx configuration to redirect to the specified target domains.
|
||||
|
||||
## Role Variables
|
||||
|
||||
- `domain_mappings`: A list of objects with `source` and `target` properties specifying the domains to redirect from and to.
|
||||
- `users.administrator.email`: The email used for SSL certificate registration with Let's Encrypt.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `sys-stk-front-pure`: A role for setting up HTTPS for Nginx
|
||||
- `letsencrypt`: A role for managing SSL certificates with Let's Encrypt
|
||||
|
||||
## Author Information
|
||||
This role was created in 2023 by [Kevin Veen-Birkenbach](https://www.veen.world/).
|
@@ -10,7 +10,7 @@
|
||||
include_role:
|
||||
name: web-opt-rdr-domains
|
||||
vars:
|
||||
domain_mappings: "{{ REDIRECT_WWW_DOMAINS | map('regex_replace', '^www\\.(.+)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}"
|
||||
redirect_domain_mappings: "{{ REDIRECT_WWW_DOMAINS | map('regex_replace', '^www\\.(.+)$', '{ source: \"www.\\1\", target: \"\\1\" }') | map('from_yaml') | list }}"
|
||||
when: REDIRECT_WWW_FLAVOR == 'origin'
|
||||
|
||||
- name: Include DNS role to set redirects
|
||||
|
@@ -61,6 +61,7 @@
|
||||
canonical_domains_map(PRIMARY_DOMAIN) |
|
||||
combine(CURRENT_PLAY_DOMAINS, recursive=True)
|
||||
}}
|
||||
|
||||
- name: Merge redirect_domain_mappings
|
||||
set_fact:
|
||||
# The following mapping is necessary to define the exceptions for domains which are created, but which aren't used
|
||||
|
173
tests/integration/test_vars_usage_in_yaml.py
Normal file
173
tests/integration/test_vars_usage_in_yaml.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Iterable, Set, List
|
||||
import yaml
|
||||
|
||||
|
||||
class TestVarsPassedAreUsed(unittest.TestCase):
|
||||
"""
|
||||
Integration test:
|
||||
- Walk all *.yml/*.yaml and *.j2 files
|
||||
- Collect variable names passed via task-level `vars:`
|
||||
- Consider a var "used" if it appears in ANY of:
|
||||
• Jinja output blocks: {{ ... var_name ... }}
|
||||
• Jinja statement blocks: {% ... var_name ... %}
|
||||
(robust against inner '}' / '%' via tempered regex)
|
||||
• Ansible expressions in YAML:
|
||||
- when: <expr> (string or list of strings)
|
||||
- loop: <expr>
|
||||
- with_*: <expr>
|
||||
|
||||
Additional rule:
|
||||
- Do NOT count as used if the token is immediately followed by '(' (optionally with whitespace),
|
||||
i.e. treat `var_name(` as a function/macro call, not a variable usage.
|
||||
"""
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
YAML_EXTENSIONS = {".yml", ".yaml"}
|
||||
JINJA_EXTENSIONS = {".j2"}
|
||||
|
||||
# ---------- File iteration & YAML loading ----------
|
||||
|
||||
def _iter_files(self, extensions: set[str]) -> Iterable[Path]:
|
||||
for p in self.REPO_ROOT.rglob("*"):
|
||||
if p.is_file() and p.suffix in extensions:
|
||||
yield p
|
||||
|
||||
def _load_yaml_documents(self, path: Path) -> List[Any]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return list(yaml.safe_load_all(f)) or []
|
||||
except Exception:
|
||||
# File may contain heavy templating or anchors; skip structural parse
|
||||
return []
|
||||
|
||||
def _walk_mapping(self, node: Any) -> Iterable[dict]:
|
||||
if isinstance(node, dict):
|
||||
yield node
|
||||
for v in node.values():
|
||||
yield from self._walk_mapping(v)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from self._walk_mapping(item)
|
||||
|
||||
# ---------- Collect vars passed via `vars:` ----------
|
||||
|
||||
def _collect_vars_passed(self) -> Set[str]:
|
||||
collected: Set[str] = set()
|
||||
for yml in self._iter_files(self.YAML_EXTENSIONS):
|
||||
docs = self._load_yaml_documents(yml)
|
||||
for doc in docs:
|
||||
for mapping in self._walk_mapping(doc):
|
||||
if "vars" in mapping and isinstance(mapping["vars"], dict):
|
||||
for k in mapping["vars"].keys():
|
||||
if isinstance(k, str) and k.strip():
|
||||
collected.add(k.strip())
|
||||
return collected
|
||||
|
||||
# ---------- Gather text for Jinja usage scanning ----------
|
||||
|
||||
def _concat_texts(self) -> str:
|
||||
parts: List[str] = []
|
||||
for f in self._iter_files(self.YAML_EXTENSIONS | self.JINJA_EXTENSIONS):
|
||||
try:
|
||||
parts.append(f.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
# Non-UTF8 or unreadable — ignore
|
||||
pass
|
||||
return "\n".join(parts)
|
||||
|
||||
# ---------- Extract Ansible expression strings from YAML ----------
|
||||
|
||||
def _collect_ansible_expressions(self) -> List[str]:
|
||||
"""
|
||||
Return a flat list of strings taken from Ansible expression-bearing fields:
|
||||
- when: <str> or when: [<str>, <str>, ...]
|
||||
- loop: <str>
|
||||
- with_*: <str>
|
||||
"""
|
||||
exprs: List[str] = []
|
||||
for yml in self._iter_files(self.YAML_EXTENSIONS):
|
||||
docs = self._load_yaml_documents(yml)
|
||||
for doc in docs:
|
||||
for mapping in self._walk_mapping(doc):
|
||||
for key, val in list(mapping.items()):
|
||||
if key == "when":
|
||||
if isinstance(val, str):
|
||||
exprs.append(val)
|
||||
elif isinstance(val, list):
|
||||
exprs.extend([x for x in val if isinstance(x, str)])
|
||||
elif key == "loop":
|
||||
if isinstance(val, str):
|
||||
exprs.append(val)
|
||||
elif isinstance(key, str) and key.startswith("with_"):
|
||||
if isinstance(val, str):
|
||||
exprs.append(val)
|
||||
return exprs
|
||||
|
||||
# ---------- Usage checks ----------
|
||||
|
||||
def _used_in_jinja_blocks(self, var_name: str, text: str) -> bool:
|
||||
"""
|
||||
Detect var usage inside Jinja blocks, excluding function/macro calls like `var_name(...)`.
|
||||
We use a tempered regex to avoid stopping at the first '}}'/'%}' and a negative lookahead
|
||||
`(?!\\s*\\()` after the token.
|
||||
"""
|
||||
# Word token not followed by '(' → real variable usage
|
||||
token = r"\b" + re.escape(var_name) + r"\b(?!\s*\()"
|
||||
|
||||
# Output blocks: {{ ... }}
|
||||
pat_output = re.compile(
|
||||
r"{{(?:(?!}}).)*" + token + r"(?:(?!}}).)*}}",
|
||||
re.DOTALL,
|
||||
)
|
||||
# Statement blocks: {% ... %}
|
||||
pat_stmt = re.compile(
|
||||
r"{%(?:(?!%}).)*" + token + r"(?:(?!%}).)*%}",
|
||||
re.DOTALL,
|
||||
)
|
||||
return pat_output.search(text) is not None or pat_stmt.search(text) is not None
|
||||
|
||||
def _used_in_ansible_exprs(self, var_name: str, exprs: List[str]) -> bool:
|
||||
"""
|
||||
Detect var usage in Ansible expressions (when/loop/with_*),
|
||||
excluding function/macro calls like `var_name(...)`.
|
||||
"""
|
||||
pat = re.compile(r"\b" + re.escape(var_name) + r"\b(?!\s*\()")
|
||||
return any(pat.search(e) for e in exprs)
|
||||
|
||||
# ---------- Test ----------
|
||||
|
||||
def test_vars_passed_are_used_in_yaml_or_jinja(self):
|
||||
vars_passed = self._collect_vars_passed()
|
||||
self.assertTrue(
|
||||
vars_passed,
|
||||
"No variables passed via `vars:` were found. "
|
||||
"Check the repo root path in this test."
|
||||
)
|
||||
|
||||
all_text = self._concat_texts()
|
||||
ansible_exprs = self._collect_ansible_expressions()
|
||||
|
||||
unused: List[str] = []
|
||||
for var_name in sorted(vars_passed):
|
||||
used = (
|
||||
self._used_in_jinja_blocks(var_name, all_text)
|
||||
or self._used_in_ansible_exprs(var_name, ansible_exprs)
|
||||
)
|
||||
if not used:
|
||||
unused.append(var_name)
|
||||
|
||||
if unused:
|
||||
msg = (
|
||||
"The following variables are passed via `vars:` but never referenced in:\n"
|
||||
" • Jinja output/statement blocks ({{ ... }} / {% ... %}) OR\n"
|
||||
" • Ansible expressions (when/loop/with_*)\n\n"
|
||||
+ "\n".join(f" - {v}" for v in unused)
|
||||
+ "\n\nNotes:\n"
|
||||
" • Function-like tokens (name followed by '(') are ignored intentionally.\n"
|
||||
" • If a var is only used in Python code or other file types, extend the test accordingly\n"
|
||||
" or remove the var if it's truly unused."
|
||||
)
|
||||
self.fail(msg)
|
Reference in New Issue
Block a user