feat(tags): checker helpers — tag collection & allowed-set
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
24397fa280
commit
b45118dac3
2 changed files with 116 additions and 0 deletions
72
scripts/check-tags.py
Normal file
72
scripts/check-tags.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Validate that every Ansible tag used under roles/ and playbooks/ belongs to the
|
||||||
|
approved vocabulary. Single source of truth: tests/tags.yml. Rationale: ADR-019.
|
||||||
|
|
||||||
|
Allowed set = {role directory names under roles/} ∪ {concerns, special, opt_ins,
|
||||||
|
playbooks from tests/tags.yml}. Templated tags (containing "{{") are skipped —
|
||||||
|
they can't be statically validated.
|
||||||
|
|
||||||
|
Usage: python3 scripts/check-tags.py
|
||||||
|
Exit 0 = all tags allowed; exit 1 = unknown tag(s) found.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
VOCAB_FILE = REPO / "tests" / "tags.yml"
|
||||||
|
SCAN_DIRS = ("roles", "playbooks")
|
||||||
|
|
||||||
|
|
||||||
|
class _IgnoreUnknownTags(yaml.SafeLoader):
|
||||||
|
"""SafeLoader that tolerates custom YAML tags (e.g. !vault) instead of crashing."""
|
||||||
|
|
||||||
|
|
||||||
|
def _ignore(loader, tag_suffix, node):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_IgnoreUnknownTags.add_multi_constructor("", _ignore)
|
||||||
|
_IgnoreUnknownTags.add_multi_constructor("!", _ignore)
|
||||||
|
|
||||||
|
|
||||||
|
def _static_str(value):
|
||||||
|
return isinstance(value, str) and "{{" not in value
|
||||||
|
|
||||||
|
|
||||||
|
def load_vocab(path=VOCAB_FILE):
|
||||||
|
data = yaml.safe_load(path.read_text()) or {}
|
||||||
|
vocab = set()
|
||||||
|
for key in ("concerns", "special", "opt_ins", "playbooks"):
|
||||||
|
vocab.update(data.get(key) or [])
|
||||||
|
return vocab
|
||||||
|
|
||||||
|
|
||||||
|
def role_names(repo=REPO):
|
||||||
|
roles_dir = repo / "roles"
|
||||||
|
if not roles_dir.is_dir():
|
||||||
|
return set()
|
||||||
|
return {p.name for p in roles_dir.iterdir() if p.is_dir()}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tags(node):
|
||||||
|
"""Recursively collect every static tag string under any 'tags:' key."""
|
||||||
|
tags = set()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
if key == "tags":
|
||||||
|
if _static_str(value):
|
||||||
|
tags.add(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
tags.update(t for t in value if _static_str(t))
|
||||||
|
tags |= collect_tags(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
tags |= collect_tags(item)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(0)
|
||||||
44
tests/test_check_tags.py
Normal file
44
tests/test_check_tags.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
_PATH = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "check-tags.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("check_tags", _PATH)
|
||||||
|
ct = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(ct)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_tags_list_form():
|
||||||
|
node = {"name": "t", "tags": ["firewall", "users"]}
|
||||||
|
assert ct.collect_tags(node) == {"firewall", "users"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_tags_string_form():
|
||||||
|
node = {"name": "t", "tags": "always"}
|
||||||
|
assert ct.collect_tags(node) == {"always"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_tags_nested_blocks_and_roles():
|
||||||
|
doc = [
|
||||||
|
{"hosts": "all", "roles": [{"role": "base", "tags": ["base"]}]},
|
||||||
|
{"block": [{"name": "x", "tags": ["config"]}], "tags": ["deploy"]},
|
||||||
|
]
|
||||||
|
assert ct.collect_tags(doc) == {"base", "config", "deploy"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_tags_ignores_templated_values():
|
||||||
|
node = {"tags": ["{{ dynamic }}", "logging"]}
|
||||||
|
assert ct.collect_tags(node) == {"logging"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_vocab_unions_all_categories():
|
||||||
|
vocab = ct.load_vocab()
|
||||||
|
assert "firewall" in vocab # concern
|
||||||
|
assert "always" in vocab # special
|
||||||
|
assert "bootstrap" in vocab # playbook identity
|
||||||
|
assert len([c for c in vocab]) >= 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_names_reads_role_dirs():
|
||||||
|
names = ct.role_names()
|
||||||
|
assert "base" in names
|
||||||
|
assert "docker_host" in names
|
||||||
Loading…
Add table
Reference in a new issue