Compare commits
2 commits
2e5a1e1e23
...
fac438cc92
| Author | SHA1 | Date | |
|---|---|---|---|
| fac438cc92 | |||
| 5aeeb094eb |
4 changed files with 178 additions and 16 deletions
|
|
@ -55,7 +55,7 @@ Full design rationale: `docs/decisions/`
|
|||
(Ansible inherits it to every task). Tag a task/block with a concern tag from the
|
||||
approved list (`tests/tags.yml`) only where it genuinely belongs to that concern —
|
||||
don't invent tags or tag for tagging's sake. Target one axis at a time (role/service
|
||||
*or* concern; tags are union/OR, never intersected). `make lint` enforces the vocabulary.
|
||||
*or* concern; tags are union/OR, never intersected). `make lint` enforces the vocabulary and that each role import carries its role-name tag.
|
||||
- **Handlers**: use `listen:` topic strings, not direct name references
|
||||
- **Variables**: `rolename__varname` double-underscore namespace for role defaults
|
||||
- **No inline vars in playbooks**: use `group_vars/` or `host_vars/` only
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ opt-in/playbook tags. `scripts/check-tags.py` (run by `make lint`, covered by
|
|||
`tests/test_check_tags.py`) scans `roles/` and `playbooks/` and fails on any tag
|
||||
outside `{role directory names} ∪ {tests/tags.yml entries}`.
|
||||
Molecule scenario files (`roles/*/molecule/**`) are excluded from the scan — they are test orchestration, not the production run-targeting surface this standard governs.
|
||||
It also checks that every role imported in a play's `roles:` block carries its own role name as a tag (additional tags are allowed).
|
||||
|
||||
## Extending the vocabulary
|
||||
|
||||
|
|
@ -104,7 +105,7 @@ leaves a paper trail.
|
|||
- Targeted runs are predictable: only two kinds of tags exist, one of them mechanical.
|
||||
- Over-tagging is structurally resisted (closed list + lint enforcement).
|
||||
- Intersection targeting is unavailable by design.
|
||||
- Authors must keep role tags = role names. The linter enforces the *vocabulary* (every tag must be a known role name or an approved tag); the role-tag-equals-role-name rule itself is a convention the linter does not separately check.
|
||||
- Authors must keep role tags = role names. `make lint` enforces both the *vocabulary* (every tag is a known role name or approved tag) and that each role import in a `roles:` block carries its own role-name tag (extra tags allowed).
|
||||
|
||||
## Related
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ Allowed set = {role directory names under roles/} ∪ {concerns, special, opt_in
|
|||
playbooks from tests/tags.yml}. Templated tags (containing "{{") are skipped —
|
||||
they can't be statically validated.
|
||||
|
||||
It also checks that every role imported in a play's roles: block carries its own
|
||||
role name as a tag (extra tags allowed); ADR-019.
|
||||
|
||||
Usage: python3 scripts/check-tags.py
|
||||
Exit 0 = all tags allowed; exit 1 = unknown tag(s) found.
|
||||
Exit 0 = OK; exit 1 = unknown tag(s) or role import(s) missing their role-name tag.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
|
@ -76,6 +79,50 @@ def scan_text(text):
|
|||
return found
|
||||
|
||||
|
||||
def _role_entry(entry):
|
||||
"""Return (role_name, tags_set) for a roles: list entry, or (None, set()) to skip."""
|
||||
if isinstance(entry, str):
|
||||
return (entry if _static_str(entry) else None, set())
|
||||
if isinstance(entry, dict):
|
||||
# Ansible accepts both `role:` and `name:` as the role identifier; `role:` wins.
|
||||
name = entry.get("role") or entry.get("name")
|
||||
if not _static_str(name):
|
||||
return (None, set())
|
||||
tags_value = entry.get("tags")
|
||||
tags = set()
|
||||
if _static_str(tags_value):
|
||||
tags.add(tags_value)
|
||||
elif isinstance(tags_value, list):
|
||||
tags.update(t for t in tags_value if _static_str(t))
|
||||
return (name, tags)
|
||||
return (None, set())
|
||||
|
||||
|
||||
def _walk_roles(node, problems):
|
||||
if isinstance(node, dict):
|
||||
roles = node.get("roles")
|
||||
# Only a play has a role-import `roles:` block, and a play always has `hosts:`.
|
||||
# The `hosts` guard avoids misreading a `roles` variable/fact in a task file.
|
||||
if "hosts" in node and isinstance(roles, list):
|
||||
for entry in roles:
|
||||
name, tags = _role_entry(entry)
|
||||
if name is not None and name not in tags:
|
||||
problems.append((name, sorted(tags)))
|
||||
for value in node.values():
|
||||
_walk_roles(value, problems)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_walk_roles(item, problems)
|
||||
|
||||
|
||||
def role_tag_problems(text):
|
||||
"""Role imports (roles: blocks) whose tags don't include their own role name."""
|
||||
problems = []
|
||||
for doc in yaml.load_all(text, Loader=_IgnoreUnknownTags):
|
||||
_walk_roles(doc, problems)
|
||||
return problems
|
||||
|
||||
|
||||
def iter_yaml_files(repo=REPO, scan_dirs=SCAN_DIRS):
|
||||
for name in scan_dirs:
|
||||
base = repo / name
|
||||
|
|
@ -96,28 +143,48 @@ def find_violations(used, allowed):
|
|||
|
||||
def main():
|
||||
allowed = load_vocab() | role_names()
|
||||
violations = []
|
||||
vocab_violations = []
|
||||
role_tag_issues = []
|
||||
for path in iter_yaml_files():
|
||||
text = path.read_text()
|
||||
try:
|
||||
used = scan_text(path.read_text())
|
||||
used = scan_text(text)
|
||||
except yaml.YAMLError as exc:
|
||||
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
||||
continue
|
||||
rel = path.relative_to(REPO)
|
||||
for tag in find_violations(used, allowed):
|
||||
violations.append((path.relative_to(REPO), tag))
|
||||
vocab_violations.append((rel, tag))
|
||||
for name, tags in role_tag_problems(text):
|
||||
role_tag_issues.append((rel, name, tags))
|
||||
|
||||
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)
|
||||
if vocab_violations or role_tag_issues:
|
||||
if vocab_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 vocab_violations:
|
||||
print(f" {relpath}: '{tag}'", file=sys.stderr)
|
||||
print(f"\nallowed: {', '.join(sorted(allowed))}", file=sys.stderr)
|
||||
if role_tag_issues:
|
||||
print(
|
||||
"error: role import(s) missing their role-name tag "
|
||||
"(ADR-019: import each role with tags: [<rolename>]):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for relpath, name, tags in role_tag_issues:
|
||||
print(
|
||||
f" {relpath}: role '{name}' has tags {tags}, must include '{name}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"check-tags: OK ({len(allowed)} tags allowed across {len(SCAN_DIRS)} dirs)")
|
||||
print(
|
||||
f"check-tags: OK ({len(allowed)} tags allowed across {len(SCAN_DIRS)} dirs; "
|
||||
"role imports verified)"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -83,3 +83,97 @@ def test_iter_yaml_files_skips_molecule(tmp_path):
|
|||
names = [p.name for p in found]
|
||||
assert "main.yml" in names
|
||||
assert "verify.yml" not in names
|
||||
|
||||
|
||||
def test_role_tag_problems_flags_missing_name():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: base
|
||||
tags: [firewall]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == [("base", ["firewall"])]
|
||||
|
||||
|
||||
def test_role_tag_problems_allows_extra_tags():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: base
|
||||
tags: [base, never]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == []
|
||||
|
||||
|
||||
def test_role_tag_problems_flags_bare_dict_import():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: base
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == [("base", [])]
|
||||
|
||||
|
||||
def test_role_tag_problems_flags_string_form():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- base
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == [("base", [])]
|
||||
|
||||
|
||||
def test_role_tag_problems_ok_when_name_present():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: docker_host
|
||||
tags: [docker_host]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == []
|
||||
|
||||
|
||||
def test_role_tag_problems_skips_templated_role():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: "{{ dynamic }}"
|
||||
tags: [foo]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == []
|
||||
|
||||
|
||||
def test_role_tag_problems_recognizes_name_key():
|
||||
text = """
|
||||
- hosts: all
|
||||
roles:
|
||||
- name: base
|
||||
tags: [firewall]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == [("base", ["firewall"])]
|
||||
|
||||
|
||||
def test_role_tag_problems_ignores_roles_variable_in_tasks():
|
||||
text = """
|
||||
- hosts: all
|
||||
tasks:
|
||||
- name: Set a fact that happens to be named roles
|
||||
ansible.builtin.set_fact:
|
||||
roles:
|
||||
- base
|
||||
- docker_host
|
||||
tags: [config]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == []
|
||||
|
||||
|
||||
def test_role_tag_problems_no_roles_key():
|
||||
text = """
|
||||
- hosts: all
|
||||
tasks:
|
||||
- name: noop
|
||||
ansible.builtin.debug:
|
||||
msg: hi
|
||||
tags: [config]
|
||||
"""
|
||||
assert ct.role_tag_problems(text) == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue