mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 04:36:40 +00:00
The credentials baker at cli/administration/inventory/credentials/__main__.py
walked `schema_creds.items()` one level deep and called `str(value)` on
every leaf. For nested credential groups like
``credentials.recaptcha = {key, secret}`` (declared in
roles/web-app-espocrm/meta/schema.yml and
roles/web-app-listmonk/meta/schema.yml) this collapsed the inner dict
into its Python repr, vault-encrypted that blob, and wrote it as a
single scalar at ``credentials.recaptcha``. The runtime ``config``
lookup then choked on the descent into ``recaptcha.key``:
The lookup plugin 'config' failed: Expected dict for 'key', got
_AnsibleTaggedStr at 'key'
Full path so far: applications['web-app-espocrm'].credentials.recaptcha.key
Current object: "{'key': '{{ CAPTCHA.RECAPTCHA.KEY }}', 'secret':
'{{ CAPTCHA.RECAPTCHA.SECRET }}'}"
(jobs 77797371397 espocrm and 77797371442 listmonk on run 26428080957.)
Introduce ``emit_credentials`` (new module: ``credentials/emit.py``)
that walks the schema recursively. Scalar leaves still vault-encrypt
via ``to_vault_block``; dict nodes recurse into a nested CommentedMap
via ``ensure_map``. The walker also tracks dotted full-key paths so
the existing ``--force``-mode override-apply logic sees the same dotted
keys it always saw.
Auto-healing for previously-corrupted host_vars: ``ensure_map``
overwrites non-CommentedMap placeholders, so the next bake replaces a
stringified ``credentials.recaptcha`` blob with the correct nested
structure. Operators with corrupted entries simply re-run the
credentials generator; no manual cleanup needed.
Package refactor (KISS/DRY/SPOT):
The 450+ LoC ``__main__.py`` was carrying four orthogonal concerns
(vault, --set parsing, recursive emission, prompts) plus orchestration.
Split by concern:
- ``credentials/vault.py``: ``is_vault_encrypted``,
``is_ruamel_vault``, ``to_vault_block`` (+ private helpers
``_vault_body``, ``_make_vault_scalar_from_text``).
- ``credentials/overrides.py``: ``parse_overrides``, ``override_for``.
- ``credentials/emit.py``: ``ensure_map``, ``emit_credentials``.
- ``credentials/prompts.py``: ``ask_for_confirmation``.
- ``credentials/__main__.py``: argparse + ``main`` + two thin mode
handlers (``_run_snippet_mode``, ``_run_default_mode``) + the
force-override helper.
``credentials/__init__.py`` re-exports the public API
(``main``, ``ask_for_confirmation``, ``parse_overrides``, etc.) so
external callers (notably ``tests/unit/cli/administration/inventory/
test_credentials.py``) keep working unchanged.
Test layout follows the split:
- ``tests/unit/cli/administration/inventory/credentials/test_vault.py``
(20 tests: vault detection, body extraction, scalar construction,
to_vault_block paths, plus the VaultHandler subprocess coverage).
- ``tests/unit/cli/administration/inventory/credentials/test_overrides.py``
(13 tests: parse_overrides + override_for incl. dotted nested keys).
- ``tests/unit/cli/administration/inventory/credentials/test_emit.py``
(10 tests: ensure_map placeholder replacement, recursive emission,
none → empty, skip_existing semantics, override precedence,
dotted-path tracking, the on-disk corruption recovery case).
- ``tests/unit/cli/administration/inventory/credentials/test_prompts.py``
(5 tests: ask_for_confirmation yes/no/garbage/case/whitespace).
``tests/unit/cli/administration/inventory/test_credentials.py`` keeps
only the end-to-end ``main()`` integration coverage (including
``test_nested_credentials_dict_recurses_instead_of_stringifying`` —
the explicit regression guard for the original failure mode).
Coverage: 47 new per-module unit tests + the 5 existing main()
integration tests, all green. ``make test`` green across all five
targets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""End-to-end integration tests for ``cli.administration.inventory.credentials.main``.
|
|
|
|
Per-module unit tests for the helper concerns live next to their
|
|
module under ``tests/unit/cli/administration/inventory/credentials/``
|
|
(``test_vault.py``, ``test_overrides.py``, ``test_emit.py``,
|
|
``test_prompts.py``)."""
|
|
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
import unittest.mock
|
|
from pathlib import Path
|
|
|
|
from cli.administration.inventory.credentials import main
|
|
from utils.cache.yaml import dump_yaml, load_yaml_any
|
|
from utils.roles.mapping import (
|
|
ROLE_FILE_META_SCHEMA,
|
|
ROLE_FILE_META_SERVICES,
|
|
ROLE_FILE_VARS_MAIN,
|
|
)
|
|
|
|
|
|
class TestCreateCredentials(unittest.TestCase):
|
|
def test_main_overrides_and_file_writing(self):
|
|
# Setup temporary files for role-path vars and inventory
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
role_path = str(Path(tmpdir) / "role")
|
|
Path(str(Path(role_path) / "meta")).mkdir(parents=True)
|
|
Path(str(Path(role_path) / "vars")).mkdir(parents=True)
|
|
# Create vars/main.yml with application_id
|
|
main_vars = {"application_id": "app_test"}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_VARS_MAIN), main_vars)
|
|
# Create config/main.yml with features disabled
|
|
config = {"features": {"central_database": False}}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SERVICES), config)
|
|
# Create schema.yml defining plain credential
|
|
schema = {
|
|
"credentials": {
|
|
"api_key": {
|
|
"description": "API key",
|
|
"algorithm": "plain",
|
|
"validation": {},
|
|
}
|
|
}
|
|
}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SCHEMA), schema)
|
|
# Prepare inventory file
|
|
inventory_file = str(Path(tmpdir) / "inventory.yml")
|
|
dump_yaml(inventory_file, {})
|
|
vault_pw_file = str(Path(tmpdir) / "pw.txt")
|
|
with Path(vault_pw_file).open("w") as f:
|
|
f.write("pw")
|
|
|
|
# Simulate ansible-vault encrypt_string output for api_key
|
|
fake_snippet = "!vault |\n $ANSIBLE_VAULT;1.1;AES256\n ENCRYPTEDVALUE"
|
|
completed = subprocess.CompletedProcess(
|
|
args=["ansible-vault"], returncode=0, stdout=fake_snippet, stderr=""
|
|
)
|
|
with unittest.mock.patch("subprocess.run", return_value=completed):
|
|
# Run main with override for credentials.api_key and force to skip prompt
|
|
sys.argv = [
|
|
"create/credentials.py",
|
|
"--role-path",
|
|
role_path,
|
|
"--inventory-file",
|
|
inventory_file,
|
|
"--vault-password-file",
|
|
vault_pw_file,
|
|
"--set",
|
|
"credentials.api_key=SECRET",
|
|
"--force",
|
|
]
|
|
# Should complete without error
|
|
main()
|
|
# Verify inventory file updated with vaulted api_key
|
|
data = load_yaml_any(inventory_file)
|
|
creds = data["applications"]["app_test"]["credentials"]
|
|
self.assertIn("api_key", creds)
|
|
# VaultScalar serializes to a vault block, safe_load returns a string containing the vault header
|
|
self.assertIsInstance(creds["api_key"], str)
|
|
self.assertTrue(creds["api_key"].lstrip().startswith("$ANSIBLE_VAULT"))
|
|
|
|
def test_main_plain_algorithm_allow_empty_plain_sets_empty_string_without_vault(
|
|
self,
|
|
):
|
|
"""
|
|
When --allow-empty-plain is used, a 'plain' credential without override
|
|
should be set to "" and *not* encrypted (no ansible-vault calls).
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
role_path = str(Path(tmpdir) / "role")
|
|
Path(str(Path(role_path) / "meta")).mkdir(parents=True)
|
|
Path(str(Path(role_path) / "vars")).mkdir(parents=True)
|
|
|
|
# vars/main.yml with application_id
|
|
main_vars = {"application_id": "app_empty_plain"}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_VARS_MAIN), main_vars)
|
|
|
|
# config/main.yml
|
|
config = {"features": {"central_database": False}}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SERVICES), config)
|
|
|
|
# schema/main.yml: plain credential *without* overrides
|
|
schema = {
|
|
"credentials": {
|
|
"api_key": {
|
|
"description": "API key",
|
|
"algorithm": "plain",
|
|
"validation": {},
|
|
}
|
|
}
|
|
}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SCHEMA), schema)
|
|
|
|
# Empty inventory file
|
|
inventory_file = str(Path(tmpdir) / "inventory.yml")
|
|
dump_yaml(inventory_file, {})
|
|
|
|
# Vault password file
|
|
vault_pw_file = str(Path(tmpdir) / "pw.txt")
|
|
with Path(vault_pw_file).open("w") as f:
|
|
f.write("pw")
|
|
|
|
# Ensure ansible-vault is *not* called at all in this scenario
|
|
def fail_run(*_args, **_kwargs):
|
|
raise AssertionError(
|
|
"ansible-vault must not be called for allow_empty_plain + empty plain"
|
|
)
|
|
|
|
with unittest.mock.patch("subprocess.run", side_effect=fail_run):
|
|
sys.argv = [
|
|
"create/credentials.py",
|
|
"--role-path",
|
|
role_path,
|
|
"--inventory-file",
|
|
inventory_file,
|
|
"--vault-password-file",
|
|
vault_pw_file,
|
|
"--allow-empty-plain",
|
|
]
|
|
main()
|
|
|
|
data = load_yaml_any(inventory_file)
|
|
creds = data["applications"]["app_empty_plain"]["credentials"]
|
|
# api_key should exist and be an empty string, not a vault block
|
|
self.assertIn("api_key", creds)
|
|
self.assertEqual(creds["api_key"], "")
|
|
|
|
def test_main_forwards_variant_to_inventory_manager(self):
|
|
"""The `--variant` CLI flag must reach `InventoryManager(variant=)` so
|
|
shared-provider discovery uses the variants.yml overlay."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
role_path = str(Path(tmpdir) / "role")
|
|
Path(str(Path(role_path) / "meta")).mkdir(parents=True)
|
|
Path(str(Path(role_path) / "vars")).mkdir(parents=True)
|
|
dump_yaml(
|
|
str(Path(role_path) / ROLE_FILE_VARS_MAIN),
|
|
{"application_id": "app_test"},
|
|
)
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SERVICES), {})
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SCHEMA), {})
|
|
|
|
inventory_file = str(Path(tmpdir) / "inventory.yml")
|
|
dump_yaml(inventory_file, {})
|
|
vault_pw_file = str(Path(tmpdir) / "pw.txt")
|
|
with Path(vault_pw_file).open("w") as f:
|
|
f.write("pw")
|
|
|
|
captured: dict = {}
|
|
|
|
class FakeManager:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
self.app_id = kwargs.get("overrides", {}).get(
|
|
"_fake_app_id", "app_test"
|
|
)
|
|
self.vault_handler = None
|
|
self.schema = {}
|
|
self.inventory = {}
|
|
|
|
def apply_schema(self):
|
|
return {"applications": {}}
|
|
|
|
with unittest.mock.patch(
|
|
"cli.administration.inventory.credentials.__main__.InventoryManager",
|
|
FakeManager,
|
|
):
|
|
sys.argv = [
|
|
"credentials",
|
|
"--role-path",
|
|
role_path,
|
|
"--inventory-file",
|
|
inventory_file,
|
|
"--vault-password-file",
|
|
vault_pw_file,
|
|
"--snippet",
|
|
"--variant",
|
|
"2",
|
|
]
|
|
main()
|
|
|
|
self.assertEqual(captured.get("variant"), 2)
|
|
|
|
def test_main_variant_defaults_to_none(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
role_path = str(Path(tmpdir) / "role")
|
|
Path(str(Path(role_path) / "meta")).mkdir(parents=True)
|
|
Path(str(Path(role_path) / "vars")).mkdir(parents=True)
|
|
dump_yaml(
|
|
str(Path(role_path) / ROLE_FILE_VARS_MAIN),
|
|
{"application_id": "app_test"},
|
|
)
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SERVICES), {})
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SCHEMA), {})
|
|
|
|
inventory_file = str(Path(tmpdir) / "inventory.yml")
|
|
dump_yaml(inventory_file, {})
|
|
vault_pw_file = str(Path(tmpdir) / "pw.txt")
|
|
with Path(vault_pw_file).open("w") as f:
|
|
f.write("pw")
|
|
|
|
captured: dict = {}
|
|
|
|
class FakeManager:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
self.app_id = "app_test"
|
|
self.vault_handler = None
|
|
self.schema = {}
|
|
self.inventory = {}
|
|
|
|
def apply_schema(self):
|
|
return {"applications": {}}
|
|
|
|
with unittest.mock.patch(
|
|
"cli.administration.inventory.credentials.__main__.InventoryManager",
|
|
FakeManager,
|
|
):
|
|
sys.argv = [
|
|
"credentials",
|
|
"--role-path",
|
|
role_path,
|
|
"--inventory-file",
|
|
inventory_file,
|
|
"--vault-password-file",
|
|
vault_pw_file,
|
|
"--snippet",
|
|
]
|
|
main()
|
|
|
|
self.assertIsNone(captured.get("variant"))
|
|
|
|
def test_nested_credentials_dict_recurses_instead_of_stringifying(self):
|
|
"""Regression for run 26428080957 jobs 77797371397 / 77797371442.
|
|
|
|
A `meta/schema.yml` `credentials.<group>.{leaf,...}` nested
|
|
block must materialise as a nested CommentedMap with one vault
|
|
block per leaf — NOT collapsed via ``str(dict)`` into the
|
|
Python-repr blob ``"{'key': '...', 'secret': '...'}"`` that
|
|
the runtime `config` lookup choked on.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
role_path = str(Path(tmpdir) / "role")
|
|
Path(str(Path(role_path) / "meta")).mkdir(parents=True)
|
|
Path(str(Path(role_path) / "vars")).mkdir(parents=True)
|
|
dump_yaml(
|
|
str(Path(role_path) / ROLE_FILE_VARS_MAIN),
|
|
{"application_id": "app_nested"},
|
|
)
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SERVICES), {})
|
|
schema = {
|
|
"credentials": {
|
|
"recaptcha": {
|
|
"key": {
|
|
"description": "key",
|
|
"algorithm": "plain",
|
|
"default": "{{ CAPTCHA.RECAPTCHA.KEY }}",
|
|
},
|
|
"secret": {
|
|
"description": "secret",
|
|
"algorithm": "plain",
|
|
"default": "{{ CAPTCHA.RECAPTCHA.SECRET }}",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
dump_yaml(str(Path(role_path) / ROLE_FILE_META_SCHEMA), schema)
|
|
|
|
inventory_file = str(Path(tmpdir) / "inventory.yml")
|
|
dump_yaml(inventory_file, {})
|
|
vault_pw_file = str(Path(tmpdir) / "pw.txt")
|
|
with Path(vault_pw_file).open("w") as f:
|
|
f.write("pw")
|
|
|
|
fake_snippet = "!vault |\n $ANSIBLE_VAULT;1.1;AES256\n ENCRYPTEDVALUE"
|
|
completed = subprocess.CompletedProcess(
|
|
args=["ansible-vault"], returncode=0, stdout=fake_snippet, stderr=""
|
|
)
|
|
with unittest.mock.patch("subprocess.run", return_value=completed):
|
|
sys.argv = [
|
|
"credentials",
|
|
"--role-path",
|
|
role_path,
|
|
"--inventory-file",
|
|
inventory_file,
|
|
"--vault-password-file",
|
|
vault_pw_file,
|
|
]
|
|
main()
|
|
|
|
data = load_yaml_any(inventory_file)
|
|
creds = data["applications"]["app_nested"]["credentials"]
|
|
self.assertIn("recaptcha", creds)
|
|
self.assertIsInstance(
|
|
creds["recaptcha"],
|
|
dict,
|
|
f"`credentials.recaptcha` must be a nested mapping, "
|
|
f"got {type(creds['recaptcha']).__name__}: {creds['recaptcha']!r}",
|
|
)
|
|
for leaf in ("key", "secret"):
|
|
self.assertIn(leaf, creds["recaptcha"])
|
|
self.assertIsInstance(creds["recaptcha"][leaf], str)
|
|
self.assertTrue(
|
|
creds["recaptcha"][leaf].lstrip().startswith("$ANSIBLE_VAULT")
|
|
)
|