Files
computer-playbook/cli/administration/inventory/credentials/__main__.py
T
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

257 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Selectively add & vault NEW credentials in your inventory, preserving
comments and formatting. Existing values are left untouched unless
``--force`` is used.
Usage example::
infinito administration inventory credentials \\
--role-path roles/web-app-akaunting \\
--inventory-file host_vars/echoserver.yml \\
--vault-password-file .pass/echoserver.txt \\
--set secrets.credentials.database_password=mysecret
Snippet mode (no file changes, YAML printed to stdout)::
infinito administration inventory credentials \\
--role-path roles/web-app-akaunting \\
--inventory-file host_vars/echoserver.yml \\
--vault-password-file .pass/echoserver.txt \\
--snippet
"""
import argparse
import sys
from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
from utils.manager.inventory import InventoryManager
from .emit import (
credentials_map,
declared_credentials,
emit_credentials,
ensure_map,
)
from .overrides import parse_overrides, split_override_key
from .prompts import ask_for_confirmation
from .vault import to_vault_block
def main() -> int:
parser = argparse.ArgumentParser(
description="Selectively add & vault NEW credentials in your inventory, preserving comments/formatting."
)
parser.add_argument("--role-path", required=True, help="Path to your role")
parser.add_argument(
"--inventory-file", required=True, help="Host vars file to update"
)
parser.add_argument(
"--vault-password-file", required=True, help="Vault password file"
)
parser.add_argument(
"--set",
nargs="*",
default=[],
help="Override values key[.subkey]=VALUE (applied to NEW keys; with --force also to existing)",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
help="Allow overrides to replace existing values (will ask per key unless combined with --yes)",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Non-interactive: assume 'yes' for all overwrite confirmations when --force is used",
)
parser.add_argument(
"--snippet",
action="store_true",
help=(
"Do not modify the inventory file. Instead, print a YAML snippet with "
"the generated credentials to stdout. The snippet contains only the "
"applications/credentials blocks that would be generated (and ansible_become_password if provided)."
),
)
parser.add_argument(
"--allow-empty-plain",
action="store_true",
help=(
"Allow 'plain' credentials in the schema without an explicit --set override. "
"Missing plain values will be set to an empty string before encryption."
),
)
parser.add_argument(
"--variant",
type=int,
default=None,
help=(
"Variant index of the role to resolve. Affects shared-provider "
"discovery (services.<key>.enabled+shared) so credentials are "
"generated for the providers actually pulled in by the variant. "
"If omitted, the role's base config is used (variants.yml overlay "
"is not applied)."
),
)
args = parser.parse_args()
overrides = parse_overrides(args.set)
manager = InventoryManager(
role_path=Path(args.role_path),
inventory_path=Path(args.inventory_file),
vault_pw=args.vault_password_file,
overrides=overrides,
allow_empty_plain=args.allow_empty_plain,
variant=args.variant,
)
yaml_rt = YAML(typ="rt")
yaml_rt.preserve_quotes = True
schema_inventory: dict[str, Any] = manager.apply_schema()
schema_apps = schema_inventory.get("applications", {}) or {}
if args.snippet:
return _run_snippet_mode(schema_apps, overrides, manager, yaml_rt)
return _run_default_mode(schema_apps, overrides, manager, yaml_rt, args)
def _run_snippet_mode(
schema_apps: dict,
overrides: dict[str, str],
manager: InventoryManager,
yaml_rt: YAML,
) -> int:
snippet_data = CommentedMap()
apps_snip = ensure_map(snippet_data, "applications")
for app_id, app_block in schema_apps.items():
if not isinstance(app_block, dict):
continue
schema_creds = declared_credentials(app_block)
if not isinstance(schema_creds, dict) or not schema_creds:
continue
app_block_snip = ensure_map(apps_snip, app_id)
creds_snip = credentials_map(app_block_snip)
emit_credentials(
schema_creds,
creds_snip,
app_id=app_id,
key_path="",
overrides=overrides,
vault_handler=manager.vault_handler,
skip_existing=False,
track_added=None,
)
if "ansible_become_password" in overrides:
snippet_data["ansible_become_password"] = to_vault_block(
manager.vault_handler,
overrides["ansible_become_password"],
"ansible_become_password",
)
yaml_rt.dump(snippet_data, sys.stdout)
return 0
def _run_default_mode(
schema_apps: dict,
overrides: dict[str, str],
manager: InventoryManager,
yaml_rt: YAML,
args: argparse.Namespace,
) -> int:
with Path(args.inventory_file).open(encoding="utf-8") as f:
data = yaml_rt.load(f)
if data is None:
data = CommentedMap()
apps = ensure_map(data, "applications")
newly_added_keys: dict[str, set[str]] = {}
for app_id, app_block_schema in schema_apps.items():
if not isinstance(app_block_schema, dict):
continue
schema_creds = declared_credentials(app_block_schema)
if not isinstance(schema_creds, dict) or not schema_creds:
continue
app_block = ensure_map(apps, app_id)
creds = credentials_map(app_block)
added = newly_added_keys.setdefault(app_id, set())
emit_credentials(
schema_creds,
creds,
app_id=app_id,
key_path="",
overrides=overrides,
vault_handler=manager.vault_handler,
skip_existing=True,
track_added=added,
)
if "ansible_become_password" not in data:
val = overrides.get("ansible_become_password")
if val is not None:
data["ansible_become_password"] = to_vault_block(
manager.vault_handler, val, "ansible_become_password"
)
elif args.force and "ansible_become_password" in overrides:
do_overwrite = args.yes or ask_for_confirmation("ansible_become_password")
if do_overwrite:
data["ansible_become_password"] = to_vault_block(
manager.vault_handler,
overrides["ansible_become_password"],
"ansible_become_password",
)
if args.force:
_apply_force_overrides(apps, overrides, newly_added_keys, manager, args)
with Path(args.inventory_file).open("w", encoding="utf-8") as f:
yaml_rt.dump(data, f)
print(
f"✅ Added new credentials without touching existing formatting/comments → {args.inventory_file}"
)
return 0
def _apply_force_overrides(
apps: CommentedMap,
overrides: dict[str, str],
newly_added_keys: dict[str, set[str]],
manager: InventoryManager,
args: argparse.Namespace,
) -> None:
"""Apply ``--set`` overrides to keys that already existed in the
inventory. Only fires under ``--force``; freshly-added keys are
skipped because they were already populated from the same override."""
for ov_key, ov_val in overrides.items():
app_id, key = split_override_key(ov_key)
if app_id not in apps:
continue
app_block = ensure_map(apps, app_id)
creds = credentials_map(app_block)
if key in creds:
if key in newly_added_keys.get(app_id, set()):
continue
if args.yes or ask_for_confirmation(f"{app_id}.secrets.credentials.{key}"):
creds[key] = to_vault_block(manager.vault_handler, ov_val, key)
if __name__ == "__main__":
sys.exit(main())