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

47 lines
1.3 KiB
Python

"""Unit tests for ``cli.administration.inventory.validate.keys``."""
from __future__ import annotations
import unittest
from cli.administration.inventory.validate.keys import recursive_keys
class TestRecursiveKeys(unittest.TestCase):
def test_empty_dict(self):
self.assertEqual(recursive_keys({}), set())
def test_non_dict_returns_empty(self):
self.assertEqual(recursive_keys(None), set())
self.assertEqual(recursive_keys("string"), set())
self.assertEqual(recursive_keys(42), set())
self.assertEqual(recursive_keys([1, 2, 3]), set())
def test_flat_keys(self):
self.assertEqual(
recursive_keys({"a": 1, "b": 2}),
{"a", "b"},
)
def test_nested_keys_are_dotted(self):
self.assertEqual(
recursive_keys({"services": {"port": 8080, "enabled": True}}),
{"services", "services.port", "services.enabled"},
)
def test_deeply_nested(self):
self.assertEqual(
recursive_keys({"a": {"b": {"c": 1}}}),
{"a", "a.b", "a.b.c"},
)
def test_prefix_is_applied(self):
self.assertEqual(
recursive_keys({"x": 1}, prefix="parent"),
{"parent.x"},
)
if __name__ == "__main__": # pragma: no cover
unittest.main()