Files
computer-playbook/cli/administration/inventory/validate/__main__.py
T
kevinveenbirkenbachandClaude Opus 4.7 5a8fe64cc7 fix(inventory/validate): accept variants.yml keys + split CLI by concern
The inventory validator at cli/administration/inventory/validate
walked `recursive_keys(host_vars) ⊆ recursive_keys(application_defaults)`
and flagged any extra key with "Missing default for <app>: <key>".
After commit 10cb02461 ("bake only variants.yml overrides into
matrix-deploy --vars") host_vars carry every key the active
variants.yml overlay declares. Since 29377e1bd
(`_build_application_defaults` reads only `meta/<topic>.yml`, not
variants.yml — to keep the variant-0 service-key leak closed), the
defaults' allow-set is artificially narrower than the role contract:
roles/web-opt-rdr-www declares `services.dashboard.{enabled,shared}`
exclusively in `meta/variants.yml`, so the validator rejects the
baked host_vars with three "Missing default" entries before
ansible-playbook even starts (job 77797115291 on run 26428083128,
distro=debian; distro-agnostic per the matrix).

Widen the validator's legal-key set per app to

    recursive_keys(defaults[app]) ∪ ⋃ recursive_keys(variants[app][i])

variants.yml IS part of the role contract; the validator's previous
"defaults only" view was incomplete. Runtime
`_build_application_defaults` stays narrow (so 29377e1bd's
variant-0-leak fix stays in place) — only the validator widens. Typos
not declared by any variant remain caught: a new
`test_key_typo_in_variants_still_rejected` unit test pins that.

Package split (KISS/DRY/SPOT), mirroring the credentials/ refactor:

`validate/__main__.py` carried six orthogonal concerns plus
orchestration in 175 LoC. Split:

- `validate/keys.py`: `recursive_keys` (pure helper).
- `validate/loaders.py`: `load_yaml_file` (with vault-block stripping)
  and `load_inventory_files` (directory scan).
- `validate/applications.py`: `compare_application_keys` (the widened
  variants-aware logic).
- `validate/users.py`: `compare_user_keys`.
- `validate/hosts.py`: `validate_host_keys`.
- `validate/__main__.py`: thin orchestrator — argparse + load defaults/
  variants/users + invoke compare_* + exit code. 75 LoC.

`validate/__init__.py` re-exports the public API
(`main`, `compare_application_keys`, `compare_user_keys`,
`load_inventory_files`, `load_yaml_file`, `recursive_keys`,
`validate_host_keys`, `PROJECT_ROOT`). External callers
(`tests/unit/cli/administration/inventory/validate/test_main.py`,
`tests/unit/cli/administration/inventory/validate/test_hosts.py`)
keep working unchanged via this re-export.

`__main__.py` uses ABSOLUTE imports (`from cli.administration.inventory.
validate.applications import ...`) instead of relative because it's
invoked as a subprocess by tests and by the deploy entrypoint; the
sys.path bootstrap at the top of the file makes `cli.…` resolvable
in that mode.

Test layout follows the split — one test file per module:

- `test_keys.py` (6 tests: empty/non-dict/flat/nested/deep/prefix).
- `test_loaders.py` (8 tests: vault stripping, missing file, broken
  YAML, top-level + recursive directory scan, empty-block skip).
- `test_applications.py` (9 tests: all-keys-match, unknown app, extra
  key, credentials skip, variants legal, variants union, no-variants
  fallback, source path in error, typo still rejected).
- `test_users.py` (5 tests: match, unknown user warn, missing default,
  password skip, credentials skip).
- `test_hosts.py` (renamed from test_inventory_hosts.py — unit tests
  for `validate_host_keys`).
- `test_main.py` (renamed from test_inventory.py — end-to-end
  subprocess tests for `main()`, including the
  `test_variants_only_key_is_accepted` regression guard for this fix).

Coverage: 36 tests, all green. `make test` green across all 5 targets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:25:14 +02:00

83 lines
2.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Inventory validator orchestration: load → compare → report."""
import argparse
import sys
from pathlib import Path
# nocheck: project-root-import sys.path bootstrap before package imports resolve
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from cli.administration.inventory.validate.applications import ( # noqa: E402
compare_application_keys,
)
from cli.administration.inventory.validate.hosts import validate_host_keys # noqa: E402
from cli.administration.inventory.validate.loaders import ( # noqa: E402
load_inventory_files,
load_yaml_file,
)
from cli.administration.inventory.validate.users import compare_user_keys # noqa: E402
from utils.cache.applications import ( # noqa: E402
get_application_defaults,
get_variants,
)
from utils.cache.users import get_user_defaults # noqa: E402
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("inventory_dir")
p.add_argument(
"--roles-dir",
default=str(PROJECT_ROOT / "roles"),
help="Path to the repository roles directory.",
)
args = p.parse_args()
application_defaults = get_application_defaults(roles_dir=args.roles_dir)
variants = get_variants(roles_dir=args.roles_dir)
user_defaults = get_user_defaults(roles_dir=args.roles_dir)
if not application_defaults:
print(
"Error: No application defaults discovered in roles directory",
file=sys.stderr,
)
sys.exit(1)
if not user_defaults:
print("Error: No user defaults discovered in roles directory", file=sys.stderr)
sys.exit(1)
app_errs: list[str] = []
inv_files = load_inventory_files(args.inventory_dir)
for src, apps in inv_files.items():
app_errs.extend(
compare_application_keys(apps, application_defaults, src, variants)
)
user_errs: list[str] = []
for fpath in Path(args.inventory_dir).rglob("*.yml"):
data = load_yaml_file(fpath)
if isinstance(data, dict) and "users" in data:
errs = compare_user_keys(data["users"], user_defaults, str(fpath))
for e in errs:
print(e, file=sys.stderr)
user_errs.extend(errs)
host_errs = validate_host_keys(set(application_defaults), args.inventory_dir)
app_errs.extend(host_errs)
if app_errs or user_errs:
if app_errs:
print("Validation failed with the following issues:")
for e in app_errs:
print(f"- {e}")
sys.exit(1)
print("Inventory directory is valid against defaults and hosts.")
sys.exit(0)
if __name__ == "__main__":
main()