Files
computer-playbook/cli/administration/inventory/validate/variables.py
T
kevinveenbirkenbachandClaude Opus 5.5 285ce910d2 feat(inventory): reject unread keys and a missing Cloudflare token before the deploy
The Keycloak stage on echoserver ran ten minutes before sys-svc-dns stopped
it with "API.cloudflare.api_token must be set": the inventory still kept the
token under the flat name CLOUDFLARE_API_TOKEN, which nothing reads any
more. The validator that runs before every deploy now catches that class.

- api.py reports a host that selects DNS_PROVIDER: cloudflare without
  API.cloudflare.api_token, with group_vars/all merged under the host_vars
- variables.py reports every top-level key of host_vars and group_vars that
  core does not read: not declared in group_vars or a role's defaults or
  vars, not tested with "is defined" or "| default(", not read by name in a
  plugin, and not a SYSTEM_EMAIL_<KEY> the email lookup builds; a flat
  credential name such as CLOUDFLARE_API_TOKEN names its API.<provider>.<key>
  path; ansible_* keys are exempt

A key that is read in a way the scan cannot see takes
"# nocheck: inventory-variable", documented in suppression.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 16:18:12 +02:00

124 lines
4.5 KiB
Python

"""Inventory variable validation: every top-level key needs a reader in core.
A key counts as read when core declares it in ``group_vars`` or a role's
``defaults`` or ``vars``, tests it with ``is defined`` or ``| default(`` in its
YAML or Jinja, reads it by name from the variables in a plugin, or is one of
the ``SYSTEM_EMAIL_<KEY>`` names the email lookup builds at run time.
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
* ``# nocheck: inventory-variable`` on, or directly above, the key's line.
"""
from __future__ import annotations
import re
from pathlib import Path
from utils import PROJECT_ROOT
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import iter_project_files, read_text
from utils.cache.yaml import load_yaml_any
from .loaders import load_yaml_file
_RULE = "inventory-variable"
_NAME = r"([A-Za-z_][A-Za-z0-9_]*)"
_HOOK = re.compile(rf"\b{_NAME}\s+is\s+(?:not\s+)?defined|\b{_NAME}\s*\|\s*default\(")
_PLUGIN_READ = re.compile(
rf"(?:variables|available_variables|hostvars\[[^\]]+\])\s*(?:\.get\(\s*|\[\s*)[\"']{_NAME}[\"']"
)
_EMAIL_KEYS = re.compile(r"RESOLUTION_ORDER = \(([^)]*)\)")
def _relative(path: str) -> tuple[str, ...]:
return Path(path).relative_to(PROJECT_ROOT).parts
def declared_variables() -> set[str]:
"""Return every variable core declares in group_vars or a role's defaults and vars.
Returns:
The top-level keys of ``group_vars/**`` and ``roles/*/{defaults,vars}/**``.
"""
names: set[str] = set()
for path in iter_project_files(extensions=(".yml", ".yaml")):
parts = _relative(path)
if parts[0] == "group_vars" or (
parts[0] == "roles" and len(parts) > 3 and parts[2] in ("defaults", "vars")
):
data = load_yaml_any(path, default_if_missing=None)
if isinstance(data, dict):
names.update(data)
return names
def read_variables() -> set[str]:
"""Return every variable core reads, declared or not.
Returns:
``declared_variables()`` plus the names core guards with ``is defined``
or ``| default(``, the names plugins read from the variables by name
and the ``SYSTEM_EMAIL_<KEY>`` names.
"""
names = declared_variables()
for path in iter_project_files(
extensions=(".yml", ".yaml", ".j2"), exclude_tests=True
):
for match in _HOOK.findall(read_text(path)):
names.update(name for name in match if name)
for path in iter_project_files(extensions=(".py",), exclude_tests=True):
if _relative(path)[0] in ("plugins", "utils", "roles", "filter_plugins"):
names.update(_PLUGIN_READ.findall(read_text(path)))
email = _EMAIL_KEYS.search(
read_text(str(PROJECT_ROOT / "plugins" / "lookup" / "email.py"))
)
names.update(
f"SYSTEM_EMAIL_{key.upper()}"
for key in re.findall(r"[\"'](\w+)[\"']", email.group(1) if email else "")
)
return names
def validate_inventory_variables(
inv_dir, known: set[str], moved: dict[str, str]
) -> list[str]:
"""Report top-level inventory variables the deploy never reads.
Args:
inv_dir: the inventory directory.
known: the variables core reads, see ``read_variables()``.
moved: flat name to the path its value moved to, see ``legacy_api_keys()``.
Returns:
One message per unread key of ``host_vars/**`` and ``group_vars/**``
that carries no ``inventory-variable`` marker.
"""
root = Path(inv_dir)
errs: list[str] = []
files = sorted(
{*root.glob("host_vars/**/*.yml"), *root.glob("group_vars/**/*.yml")}
)
for path in files:
data = load_yaml_file(path)
if not isinstance(data, dict):
continue
lines = read_text(str(path)).splitlines()
for key in data:
name = str(key)
if (name in known and name not in moved) or name.startswith("ansible_"):
continue
number = next(
(n for n, line in enumerate(lines, 1) if line.startswith(f"{name}:")),
0,
)
if number and is_suppressed_at(lines, number, _RULE):
continue
hint = (
f"move its value to {moved[name]}"
if name in moved
else "no group_vars, role defaults or role vars declare it and no plugin reads it"
)
errs.append(f"{path}: '{name}' is not read by the deploy; {hint}")
return errs