#!/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 create credentials \ --role-path roles/web-app-akaunting \ --inventory-file host_vars/echoserver.yml \ --vault-password-file .pass/echoserver.txt \ --set credentials.database_password=mysecret With snippet mode (no file changes, just YAML output): infinito create 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 Dict, Any, Union from ruamel.yaml import YAML from ruamel.yaml.comments import CommentedMap from utils.manager.inventory import InventoryManager from utils.handler.vault import ( VaultHandler, VaultScalar, ) # uses your existing handler # ---------- helpers ---------- def ask_for_confirmation(key: str) -> bool: """Prompt the user for confirmation to overwrite an existing value.""" confirmation = ( input(f"Are you sure you want to overwrite the value for '{key}'? (y/n): ") .strip() .lower() ) return confirmation == "y" def ensure_map(node: CommentedMap, key: str) -> CommentedMap: """ Ensure node[key] exists and is a mapping (CommentedMap) for round-trip safety. """ if key not in node or not isinstance(node.get(key), CommentedMap): node[key] = CommentedMap() return node[key] def _is_ruamel_vault(val: Any) -> bool: """Detect if a ruamel scalar already carries the !vault tag.""" try: return getattr(val, "tag", None) == "!vault" except Exception: return False def _is_vault_encrypted(val: Any) -> bool: """ Detect if value is already a vault string, a ruamel !vault scalar, or our internal VaultScalar (from InventoryManager.apply_schema()). """ if isinstance(val, VaultScalar): return True if _is_ruamel_vault(val): return True if isinstance(val, str) and ("$ANSIBLE_VAULT" in val or "!vault" in val): return True return False def _vault_body(text: str) -> str: """ Return only the vault body starting from the first line that contains '$ANSIBLE_VAULT'. If not found, return the original text. Also strips any leading '!vault |' header if present. """ lines = text.splitlines() for i, ln in enumerate(lines): if "$ANSIBLE_VAULT" in ln: return "\n".join(lines[i:]) return text def _make_vault_scalar_from_text(text: str) -> Any: """ Build a ruamel object representing a literal block scalar tagged with !vault by parsing a tiny YAML snippet. This avoids depending on yaml_set_tag(). """ body = _vault_body(text) indented = " " + body.replace("\n", "\n ") # proper block scalar indentation snippet = f"v: !vault |\n{indented}\n" y = YAML(typ="rt") return y.load(snippet)["v"] def to_vault_block( vault_handler: VaultHandler, value: Union[str, Any], label: str ) -> Any: """ Return a ruamel scalar tagged as !vault. If the input value is already vault-encrypted (string contains $ANSIBLE_VAULT, is a !vault scalar, or a VaultScalar), reuse/wrap. Otherwise, encrypt plaintext via ansible-vault. Special rule: - Empty strings ("") are NOT encrypted and are returned as plain "". """ # Empty strings should not be encrypted if isinstance(value, str) and value == "": return "" # Already a ruamel !vault scalar → reuse if _is_ruamel_vault(value): return value # InventoryManager provides VaultScalar (vault body). Wrap it. if isinstance(value, VaultScalar): return _make_vault_scalar_from_text(str(value)) # Already an encrypted string (may include '!vault |' or just the header) if isinstance(value, str) and ("$ANSIBLE_VAULT" in value or "!vault" in value): return _make_vault_scalar_from_text(value) # Plaintext → encrypt now snippet = vault_handler.encrypt_string(str(value), label) return _make_vault_scalar_from_text(snippet) def parse_overrides(pairs: list[str]) -> Dict[str, str]: """ Parse --set key=value pairs into a dict. Supports both 'credentials.key=val' and 'key=val' (short) forms. """ out: Dict[str, str] = {} for pair in pairs: k, v = pair.split("=", 1) out[k.strip()] = v.strip() return out def _override_for( app_id: str, key: str, overrides: Dict[str, str], *, is_primary: bool ) -> str | None: """ Resolve overrides for a credential key. Supported forms: - applications..credentials.=... - .credentials.=... Backwards compatible (PRIMARY app only): - credentials.=... - =... """ v = overrides.get(f"applications.{app_id}.credentials.{key}") if v is not None: return v v = overrides.get(f"{app_id}.credentials.{key}") if v is not None: return v if is_primary: v = overrides.get(f"credentials.{key}") if v is not None: return v v = overrides.get(key) if v is not None: return v return None # ---------- main ---------- 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." ), ) args = parser.parse_args() overrides = parse_overrides(args.set) # Initialize inventory manager (provides schema + app_id + vault) 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, ) yaml_rt = YAML(typ="rt") yaml_rt.preserve_quotes = True # Get schema-applied structure (includes shared-provider application blocks) schema_inventory: Dict[str, Any] = manager.apply_schema() schema_apps = schema_inventory.get("applications", {}) or {} # ------------------------------------------------------------------------- # SNIPPET MODE: only build a YAML fragment and print to stdout, no file I/O # ------------------------------------------------------------------------- if args.snippet: 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 = app_block.get("credentials", {}) if not isinstance(schema_creds, dict) or not schema_creds: continue app_block_snip = ensure_map(apps_snip, app_id) creds_snip = ensure_map(app_block_snip, "credentials") for key, default_val in schema_creds.items(): ov = _override_for( app_id, key, overrides, is_primary=(app_id == manager.app_id) ) if ov is not None: value_for_key: Union[str, Any] = ov else: value_for_key = default_val # Default rule: if schema provided vault, reuse; otherwise encrypt generated/plain if _is_vault_encrypted(value_for_key): creds_snip[key] = to_vault_block( manager.vault_handler, value_for_key, key ) else: # if schema didn't provide a value, treat as empty string if value_for_key is None: value_for_key = "" creds_snip[key] = to_vault_block( manager.vault_handler, str(value_for_key), key ) # Optional ansible_become_password only if provided via overrides 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 # ------------------------------------------------------------------------- # DEFAULT MODE: modify the inventory file on disk (preserve formatting) # ------------------------------------------------------------------------- # 1) Load existing inventory with ruamel (round-trip) with open(args.inventory_file, "r", encoding="utf-8") as f: data = yaml_rt.load(f) # CommentedMap or None if data is None: data = CommentedMap() # 2) Ensure structural path exists apps = ensure_map(data, "applications") # 3) Add ONLY missing credential keys for ALL schema apps (primary + shared providers) 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 = app_block_schema.get("credentials", {}) if not isinstance(schema_creds, dict) or not schema_creds: continue app_block = ensure_map(apps, app_id) creds = ensure_map(app_block, "credentials") newly_added_keys.setdefault(app_id, set()) for key, default_val in schema_creds.items(): if key in creds: continue ov = _override_for( app_id, key, overrides, is_primary=(app_id == manager.app_id) ) value_for_new_key: Union[str, Any] if ov is not None: value_for_new_key = ov else: value_for_new_key = default_val if _is_vault_encrypted(value_for_new_key): creds[key] = to_vault_block( manager.vault_handler, value_for_new_key, key ) else: if value_for_new_key is None: value_for_new_key = "" creds[key] = to_vault_block( manager.vault_handler, str(value_for_new_key), key ) newly_added_keys[app_id].add(key) # 4) ansible_become_password: only add if missing; # never rewrite an existing one unless --force (+ confirm/--yes) and override provided. if "ansible_become_password" not in data: val = overrides.get("ansible_become_password", None) if val is not None: data["ansible_become_password"] = to_vault_block( manager.vault_handler, val, "ansible_become_password" ) else: if 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", ) # 5) Overrides for existing credential keys (only with --force) if args.force: # Apply overrides only for keys that map to an existing application credential # (supports applications..credentials. and .credentials.) for ov_key, ov_val in overrides.items(): if ov_key.startswith("applications.") and ".credentials." in ov_key: # applications..credentials. rest = ov_key[len("applications.") :] app_id, tail = rest.split(".credentials.", 1) key = tail elif ".credentials." in ov_key: # .credentials. app_id, key = ov_key.split(".credentials.", 1) else: # legacy: only apply to primary app app_id = manager.app_id key = ( ov_key.split(".", 1)[1] if ov_key.startswith("credentials.") else ov_key ) if app_id not in apps: continue app_block = ensure_map(apps, app_id) creds = ensure_map(app_block, "credentials") 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}.credentials.{key}"): creds[key] = to_vault_block(manager.vault_handler, ov_val, key) # 6) Write back with ruamel (preserve formatting & comments) with open(args.inventory_file, "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 if __name__ == "__main__": sys.exit(main())