Files
computer-playbook/cli/administration/inventory/validate/api.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

77 lines
2.4 KiB
Python

"""API credential validation: moved credential names and a missing Cloudflare token."""
from __future__ import annotations
from pathlib import Path
from utils.api import declared_api
from .loaders import load_yaml_file
def legacy_api_keys(declared: set[str]) -> dict[str, str]:
"""Map the flat names older inventories gave API credentials to their ``API`` path.
Args:
declared: the variables core reads, see ``read_variables()``.
Returns:
``<PROVIDER>_<KEY>`` in upper case to ``API.<provider>.<key>``, without
the names core still reads as a variable of their own.
"""
legacy = {
f"{provider}_{key}".upper(): f"API.{provider}.{key}"
for provider, keys in declared_api().items()
for key in keys
}
return {name: path for name, path in legacy.items() if name not in declared}
def _cloudflare_token(data: dict) -> str:
api = data.get("API")
provider = api.get("cloudflare") if isinstance(api, dict) else None
token = provider.get("api_token") if isinstance(provider, dict) else None
return str(token or "").strip()
def _merged(paths: list[Path]) -> dict:
merged: dict = {}
for path in paths:
data = load_yaml_file(path)
if isinstance(data, dict):
merged.update(data)
return merged
def validate_api_keys(inv_dir) -> list[str]:
"""Report hosts that select Cloudflare DNS without a Cloudflare token.
Args:
inv_dir: the inventory directory.
Returns:
One message per host whose variables select ``DNS_PROVIDER: cloudflare``
without ``API.cloudflare.api_token``.
"""
root = Path(inv_dir)
group_all = _merged(
sorted(root.glob("group_vars/all.yml"))
+ sorted(root.glob("group_vars/all/*.yml"))
)
hosts = [(path, [path]) for path in sorted(root.glob("host_vars/*.yml"))]
hosts += [
(path, sorted(path.glob("*.yml")))
for path in sorted(root.glob("host_vars/*"))
if path.is_dir()
]
errs: list[str] = []
for path, files in hosts or [(root / "group_vars", [])]:
effective = {**group_all, **_merged(files)}
if effective.get("DNS_PROVIDER") == "cloudflare" and not _cloudflare_token(
effective
):
errs.append(
f"{path}: DNS_PROVIDER is cloudflare, but API.cloudflare.api_token is empty"
)
return errs