feat(tags): scan roles/+playbooks/ and fail on unknown tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-06 09:33:12 +02:00
parent b45118dac3
commit a3ea2aceb2
2 changed files with 78 additions and 4 deletions

View file

@ -29,7 +29,6 @@ def _ignore(loader, tag_suffix, node):
_IgnoreUnknownTags.add_multi_constructor("", _ignore)
_IgnoreUnknownTags.add_multi_constructor("!", _ignore)
def _static_str(value):
@ -68,5 +67,52 @@ def collect_tags(node):
return tags
if __name__ == "__main__": # pragma: no cover
sys.exit(0)
def scan_text(text):
"""Collect static tags from a (possibly multi-document) YAML string."""
found = set()
for doc in yaml.load_all(text, Loader=_IgnoreUnknownTags):
found |= collect_tags(doc)
return found
def iter_yaml_files(repo=REPO, scan_dirs=SCAN_DIRS):
for name in scan_dirs:
base = repo / name
if not base.is_dir():
continue
for ext in ("*.yml", "*.yaml"):
yield from sorted(base.rglob(ext))
def find_violations(used, allowed):
return sorted(used - allowed)
def main():
allowed = load_vocab() | role_names()
violations = []
for path in iter_yaml_files():
try:
used = scan_text(path.read_text())
except yaml.YAMLError as exc:
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
continue
for tag in find_violations(used, allowed):
violations.append((path.relative_to(REPO), tag))
if violations:
print(
"error: Ansible tag(s) not in tests/tags.yml or role names "
"(see docs/decisions/019-tagging.md):",
file=sys.stderr,
)
for relpath, tag in violations:
print(f" {relpath}: '{tag}'", file=sys.stderr)
print(f"\nallowed: {', '.join(sorted(allowed))}", file=sys.stderr)
sys.exit(1)
print(f"check-tags: OK ({len(allowed)} tags allowed across {len(SCAN_DIRS)} dirs)")
if __name__ == "__main__":
main()

View file

@ -35,10 +35,38 @@ def test_load_vocab_unions_all_categories():
assert "firewall" in vocab # concern
assert "always" in vocab # special
assert "bootstrap" in vocab # playbook identity
assert len([c for c in vocab]) >= 12
assert len(vocab) >= 10
def test_role_names_reads_role_dirs():
names = ct.role_names()
assert "base" in names
assert "docker_host" in names
def test_scan_text_collects_from_yaml_string():
text = """
- hosts: all
roles:
- role: base
tags: [base]
tasks:
- name: open port
tags: [firewall]
"""
assert ct.scan_text(text) == {"base", "firewall"}
def test_scan_text_tolerates_custom_yaml_tags():
text = "- name: t\n secret: !vault xxx\n tags: [users]\n"
assert ct.scan_text(text) == {"users"}
def test_find_violations_flags_unknown_tag():
allowed = {"base", "firewall"}
used = {"base", "frewall"} # typo
assert ct.find_violations(used, allowed) == ["frewall"]
def test_find_violations_empty_when_all_allowed():
assert ct.find_violations({"base", "firewall"}, {"base", "firewall"}) == []