Files
kevinveenbirkenbachandClaude Opus 5 f713ba8350 refactor(credentials): address them at secrets.credentials, like every other meta file
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>
2026-08-12 16:51:56 +02:00

95 lines
3.2 KiB
Python

"""Recursive credentials emission into a ruamel CommentedMap."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ruamel.yaml.comments import CommentedMap
from utils.manager.credential_key import CREDENTIALS_KEY, SECRETS_KEY
from .overrides import override_for
from .vault import is_vault_encrypted, to_vault_block
if TYPE_CHECKING:
from utils.handler.vault import VaultHandler
def ensure_map(node: CommentedMap, key: str) -> CommentedMap:
"""Ensure ``node[key]`` exists and is a CommentedMap for round-trip
safety. Overwrites non-CommentedMap placeholders."""
if key not in node or not isinstance(node.get(key), CommentedMap):
node[key] = CommentedMap()
return node[key]
def credentials_map(app_block: CommentedMap) -> CommentedMap:
"""The writable ``secrets.credentials`` node of one application block.
Args:
app_block: the ``applications.<app_id>`` map.
"""
return ensure_map(ensure_map(app_block, SECRETS_KEY), CREDENTIALS_KEY)
def declared_credentials(app_block: dict) -> dict:
"""The credentials an application block declares, empty when it declares
none. Reading a malformed ``secrets`` raises rather than resolving empty.
Args:
app_block: the ``applications.<app_id>`` map.
"""
return app_block.get(SECRETS_KEY, {}).get(CREDENTIALS_KEY, {})
def emit_credentials(
schema_node: dict,
dest_node: CommentedMap,
*,
app_id: str,
key_path: str,
overrides: dict[str, str],
vault_handler: VaultHandler,
skip_existing: bool,
track_added: set[str] | None,
) -> None:
"""Walk a (possibly nested) credentials schema and emit one vault
block per scalar leaf into ``dest_node``. Nested dicts (e.g.
``secrets.credentials.recaptcha = {key, secret}``) recurse into nested
CommentedMaps so each leaf becomes its own vault-encrypted entry
instead of being collapsed via ``str(dict)`` into a Python-repr
blob (the regression that broke run 26428080957 jobs 77797371397
and 77797371442 for web-app-espocrm and web-app-listmonk)."""
for key, default_val in schema_node.items():
full_key = f"{key_path}.{key}" if key_path else key
if isinstance(default_val, dict) and not is_vault_encrypted(default_val):
sub = ensure_map(dest_node, key)
emit_credentials(
default_val,
sub,
app_id=app_id,
key_path=full_key,
overrides=overrides,
vault_handler=vault_handler,
skip_existing=skip_existing,
track_added=track_added,
)
continue
if skip_existing and key in dest_node:
continue
ov = override_for(app_id, full_key, overrides)
value_for_key: str | Any = ov if ov is not None else default_val
if is_vault_encrypted(value_for_key):
dest_node[key] = to_vault_block(vault_handler, value_for_key, key)
else:
if value_for_key is None:
value_for_key = ""
dest_node[key] = to_vault_block(vault_handler, str(value_for_key), key)
if track_added is not None:
track_added.add(full_key)