mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
meta/schema.yml became meta/secrets.yml, but its content still landed at
applications.<app>.credentials — the one meta file whose payload did not sit
under a root named after the file. Every other topic obeys that rule:
meta/volumes.yml goes to volumes, meta/rbac.yml to rbac. Credentials now go to
secrets.credentials.
The path exists exactly once, in utils/manager/credential_key.py, and
OVERRIDE_SECTION is derived from its two segments rather than spelled beside
them, so the dotted form used by --set and the nested form used by the
inventory cannot drift. Six consumers read it: the loader, the generator, the
CLI override parser, the two emit helpers, and the four lookup validators.
--set now accepts exactly one key shape,
applications.<app>.secrets.credentials.<key>. The three short forms are
rejected instead of guessed at: the same short key names different credentials
on different apps. That also retired primary_app_id, which only existed to
disambiguate them.
Three defects surfaced while wiring this up. _apply_one_role_schema still wrote
to the old root, so an app's own credentials would have moved while its
providers' stayed behind. web-app-pgadmin reads through
lookup('applications', ...).credentials.<key>, an access path no regex over
lookup('config', ...) could see, and would have deployed with an empty postgres
password. And _META_NON_GENERIC_TOPICS still listed 'schema' — a bare topic
name, invisible to any path search — so the generic file-root loop assigned the
raw schema under secrets and the follow-up write landed inside the cached
document, truncating keycloak's four credentials to one for every later
reader.
tests/integration/ansible/lookups/config/test_credentials_roundtrip.py pins both
directions of the seam and the cache invariant that last defect broke.
Existing inventories still carry the pre-rename root; migrating them is
deliberately out of scope here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""Pure validation helpers shared by the `tests/integration/lookups/config`
|
|
test modules. Kept separate from `_scan.py` so each test class wires
|
|
exactly the helpers it needs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
from collections.abc import Mapping
|
|
from typing import Any
|
|
|
|
from utils.manager.credential_key import (
|
|
CREDENTIALS_KEY,
|
|
OVERRIDE_SECTION,
|
|
SECRETS_KEY,
|
|
)
|
|
|
|
|
|
class PathNotFoundError(Exception):
|
|
"""Raised by helpers when a dotted path does not resolve."""
|
|
|
|
|
|
def assert_nested(mapping: Mapping[str, Any], dotted: str, context: str) -> None:
|
|
"""Walk ``dotted`` through ``mapping``; raise ``PathNotFoundError`` on miss."""
|
|
keys = dotted.split(".")
|
|
cur: Any = mapping
|
|
for k in keys:
|
|
if not isinstance(cur, Mapping):
|
|
raise PathNotFoundError(f"{context}: expected dict at {k}")
|
|
if k not in cur:
|
|
raise PathNotFoundError(f"{context}: missing '{k}' in '{dotted}'")
|
|
cur = cur[k]
|
|
|
|
|
|
def match_wildcard_segment(mapping: Any, segment: str) -> bool:
|
|
"""Return True when ``segment`` matches at least one top-level key.
|
|
|
|
``segment`` may contain ``*`` glob characters; bare ``*`` matches any
|
|
key.
|
|
"""
|
|
if not isinstance(mapping, Mapping):
|
|
return False
|
|
if "*" not in segment:
|
|
return segment in mapping
|
|
return any(fnmatch.fnmatchcase(k, segment) for k in mapping)
|
|
|
|
|
|
def match_wildcard_path(mapping: Any, dotted: str) -> bool:
|
|
"""Return True when ``dotted`` matches at least one nested path in
|
|
``mapping``. Each segment may be a literal key, a bare ``*``
|
|
(matches any single key), or a glob containing ``*`` (matches any
|
|
key whose name matches the glob, e.g. ``*_jwt_secret``)."""
|
|
keys = dotted.split(".")
|
|
|
|
def walk(cur: Any, idx: int) -> bool:
|
|
if idx == len(keys):
|
|
return True
|
|
if not isinstance(cur, Mapping):
|
|
return False
|
|
key = keys[idx]
|
|
if "*" not in key:
|
|
return key in cur and walk(cur[key], idx + 1)
|
|
return any(
|
|
fnmatch.fnmatchcase(child_key, key) and walk(child_val, idx + 1)
|
|
for child_key, child_val in cur.items()
|
|
)
|
|
|
|
return walk(mapping, 0)
|
|
|
|
|
|
def validate_app_path(
|
|
application_defaults: dict[str, Any],
|
|
role_schemas: dict[str, dict[str, Any]],
|
|
user_defaults: dict[str, Any],
|
|
app_id: str,
|
|
dotted: str,
|
|
) -> None:
|
|
"""Resolve ``dotted`` against the same fallback chain the runtime
|
|
plugin uses: app defaults → users → credentials in defaults →
|
|
credentials in schema → images presence. Raise ``PathNotFoundError`` on
|
|
a final miss."""
|
|
cfg = application_defaults.get(app_id, {})
|
|
try:
|
|
assert_nested(cfg, dotted, app_id)
|
|
except PathNotFoundError:
|
|
pass
|
|
else:
|
|
return
|
|
if dotted.startswith("users."):
|
|
sub = dotted.split(".", 1)[1]
|
|
if sub in user_defaults:
|
|
return
|
|
if dotted.startswith(f"{OVERRIDE_SECTION}."):
|
|
key = dotted.split(f"{OVERRIDE_SECTION}.", 1)[1]
|
|
secrets_cfg = cfg.get(SECRETS_KEY, {}) if isinstance(cfg, Mapping) else {}
|
|
creds_cfg = (
|
|
secrets_cfg.get(CREDENTIALS_KEY, {})
|
|
if isinstance(secrets_cfg, Mapping)
|
|
else {}
|
|
)
|
|
if isinstance(creds_cfg, Mapping) and key in creds_cfg:
|
|
return
|
|
schema = role_schemas.get(app_id, {})
|
|
creds = schema.get(CREDENTIALS_KEY, {}) if isinstance(schema, Mapping) else {}
|
|
if isinstance(creds, Mapping) and key in creds:
|
|
return
|
|
raise PathNotFoundError(f"Credential '{key}' missing for app '{app_id}'")
|
|
if dotted.startswith("images.") and isinstance(cfg.get("images"), Mapping):
|
|
return
|
|
raise PathNotFoundError(f"'{dotted}' not found for '{app_id}'")
|