Refactor defaults generation, credential creation, and inventory management

### Overview
This commit introduces a broad set of improvements across the defaults
generator, credential creation subsystem, inventory creation workflow,
and InventoryManager core logic.

### Major Changes
- Support empty or  config/main.yml in defaults generator and ensure that
  applications with empty configs are still included in defaults_applications.
- Add '--snippet' and '--allow-empty-plain' modes to create/credentials.py
  with non-destructive merging and correct plain-secret handling.
- Ensure empty strings for 'plain' credentials are never encrypted.
- Update InventoryManager to fully support allow_empty_plain and prevent
  accidental overwriting or encrypting existing VaultScalar or dict values.
- Add full-size implementation of cli/create/inventory.py including
  dynamic inventory building, role filtering, host_vars management, and
  parallelised credential snippet generation.
- Fix schemas (Magento, Nextcloud, OAuth2-Proxy, keyboard-color, etc.) to
  align with the new credential model and avoid test failures.
- Improve get_app_conf consistency by ensuring credentials.* paths are
  always resolvable for applications even when config/main.yml is empty.

### Added Test Coverage
- Unit tests for defaults generator handling empty configs.
- Full test suite for create/inventory.py including merge logic and
  vault-safe host_vars loading.
- Extensive tests for InventoryManager: plain-secret behavior,
  vault handling, and recursion logic.
- Update or remove outdated tests referencing old schema behaviour.

### Context
This commit is associated with a refactoring and debugging session documented here:
https://chatgpt.com/share/692ec0e1-5018-800f-b568-d09a53e9d0ee
This commit is contained in:
2025-12-02 11:54:55 +01:00
parent 5320a5d20c
commit c0e26275f8
22 changed files with 1566 additions and 186 deletions

View File

@@ -9,6 +9,14 @@ Usage example:
--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
@@ -92,7 +100,14 @@ def to_vault_block(vault_handler: VaultHandler, value: Union[str, Any], label: s
Return a ruamel scalar tagged as !vault. If the input value is already
vault-encrypted (string contains $ANSIBLE_VAULT or is a !vault scalar), 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
@@ -105,7 +120,6 @@ def to_vault_block(vault_handler: VaultHandler, value: Union[str, Any], label: s
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.
@@ -139,6 +153,23 @@ def main() -> int:
"-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 "
"application's credentials (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)
@@ -148,36 +179,82 @@ def main() -> int:
role_path=Path(args.role_path),
inventory_path=Path(args.inventory_file),
vault_pw=args.vault_password_file,
overrides=overrides
overrides=overrides,
allow_empty_plain=args.allow_empty_plain,
)
# 1) Load existing inventory with ruamel (round-trip)
yaml_rt = YAML(typ="rt")
yaml_rt.preserve_quotes = True
# Get schema-applied structure (defaults etc.) for *non-destructive* merge
schema_inventory: Dict[str, Any] = manager.apply_schema()
schema_apps = schema_inventory.get("applications", {})
schema_app_block = schema_apps.get(manager.app_id, {})
schema_creds = schema_app_block.get("credentials", {}) if isinstance(schema_app_block, dict) else {}
# -------------------------------------------------------------------------
# SNIPPET MODE: only build a YAML fragment and print to stdout, no file I/O
# -------------------------------------------------------------------------
if args.snippet:
# Build a minimal structure:
# applications:
# <app_id>:
# credentials:
# key: !vault |
# ...
# ansible_become_password: !vault | ...
snippet_data = CommentedMap()
apps_snip = ensure_map(snippet_data, "applications")
app_block_snip = ensure_map(apps_snip, manager.app_id)
creds_snip = ensure_map(app_block_snip, "credentials")
for key, default_val in schema_creds.items():
# Priority: --set exact key → default from schema → empty string
ov = overrides.get(f"credentials.{key}", None)
if ov is None:
ov = overrides.get(key, None)
if ov is not None:
value_for_key: Union[str, Any] = ov
else:
if _is_vault_encrypted(default_val):
creds_snip[key] = to_vault_block(manager.vault_handler, default_val, key)
continue
value_for_key = "" if default_val is None else str(default_val)
creds_snip[key] = to_vault_block(manager.vault_handler, 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 (previous behavior)
# -------------------------------------------------------------------------
# 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) Get schema-applied structure (defaults etc.) for *non-destructive* merge
schema_inventory: Dict[str, Any] = manager.apply_schema()
# 3) Ensure structural path exists
# 2) Ensure structural path exists
apps = ensure_map(data, "applications")
app_block = ensure_map(apps, manager.app_id)
creds = ensure_map(app_block, "credentials")
# 4) Determine defaults we could add
schema_apps = schema_inventory.get("applications", {})
schema_app_block = schema_apps.get(manager.app_id, {})
schema_creds = schema_app_block.get("credentials", {}) if isinstance(schema_app_block, dict) else {}
# 5) Add ONLY missing credential keys
# 3) Add ONLY missing credential keys (respect existing values)
newly_added_keys = set()
for key, default_val in schema_creds.items():
if key in creds:
# existing → do not touch (preserve plaintext/vault/formatting/comments)
# Existing → do not touch (preserve plaintext/vault/formatting/comments)
continue
# Value to use for the new key
@@ -200,7 +277,7 @@ def main() -> int:
creds[key] = to_vault_block(manager.vault_handler, value_for_new_key, key)
newly_added_keys.add(key)
# 6) ansible_become_password: only add if missing;
# 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)
@@ -216,7 +293,7 @@ def main() -> int:
manager.vault_handler, overrides["ansible_become_password"], "ansible_become_password"
)
# 7) Overrides for existing credential keys (only with --force)
# 5) Overrides for existing credential keys (only with --force)
if args.force:
for ov_key, ov_val in overrides.items():
# Accept both 'credentials.key' and bare 'key'
@@ -228,7 +305,7 @@ def main() -> int:
if args.yes or ask_for_confirmation(key):
creds[key] = to_vault_block(manager.vault_handler, ov_val, key)
# 8) Write back with ruamel (preserve formatting & comments)
# 6) Write back with ruamel (preserve formatting & comments)
with open(args.inventory_file, "w", encoding="utf-8") as f:
yaml_rt.dump(data, f)