mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
251 lines
9.5 KiB
Python
251 lines
9.5 KiB
Python
import tempfile
|
|
from pathlib import Path
|
|
from unittest import TestCase, main
|
|
from unittest.mock import patch
|
|
|
|
from utils.handler.vault import VaultScalar
|
|
from utils.manager.inventory import InventoryManager
|
|
from utils.roles.mapping import (
|
|
ROLE_FILE_META_SECRETS,
|
|
ROLE_FILE_META_SERVICES,
|
|
ROLE_FILE_VARS_MAIN,
|
|
)
|
|
|
|
|
|
class _FakeVaultHandler:
|
|
"""
|
|
Fake VaultHandler for integration tests.
|
|
Avoids calling ansible-vault / subprocess, but still returns a vault-like snippet
|
|
that InventoryManager can parse into a VaultScalar body.
|
|
"""
|
|
|
|
def __init__(self, vault_pw: str) -> None:
|
|
self.vault_pw = vault_pw
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
def encrypt_string(self, plaintext: str, key_name: str) -> str:
|
|
self.calls.append((plaintext, key_name))
|
|
|
|
return (
|
|
f"!vault |\n $ANSIBLE_VAULT;1.1;AES256\n PLAIN:{key_name}:{plaintext}\n"
|
|
)
|
|
|
|
|
|
class TestInventoryManagerIntegration(TestCase):
|
|
def test_apply_schema_with_transitive_provider_role_resolution(self):
|
|
"""
|
|
Integration-style test (REAL provider resolution):
|
|
- Writes real YAML files to disk for:
|
|
- root role: roles/web-app-demo
|
|
- provider role: roles/svc-db-mariadb
|
|
- Uses real YamlHandler parsing
|
|
- Patches only VaultHandler to avoid external ansible-vault calls
|
|
- Verifies:
|
|
- root role generates plain feature-based credentials
|
|
- schema credentials are vaulted
|
|
- provider role credentials are vaulted transitively
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp = Path(tmpdir)
|
|
|
|
roles_root = tmp / "roles"
|
|
roles_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
provider_role = roles_root / "svc-db-mariadb"
|
|
(provider_role / "meta").mkdir(parents=True, exist_ok=True)
|
|
(provider_role / "vars").mkdir(parents=True, exist_ok=True)
|
|
(provider_role / "config").mkdir(parents=True, exist_ok=True)
|
|
|
|
(provider_role / ROLE_FILE_VARS_MAIN).write_text(
|
|
'application_id: "svc-db-mariadb"\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
(provider_role / ROLE_FILE_META_SERVICES).write_text(
|
|
"mariadb:\n enabled: false\n shared: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
(provider_role / ROLE_FILE_META_SECRETS).write_text(
|
|
"credentials:\n"
|
|
" root_password:\n"
|
|
" description: MariaDB root password\n"
|
|
" algorithm: random_hex_16\n"
|
|
" validation: {}\n"
|
|
" replication_password:\n"
|
|
" description: MariaDB replication password\n"
|
|
" algorithm: random_hex_16\n"
|
|
" validation: {}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
role_path = roles_root / "web-app-demo"
|
|
(role_path / "meta").mkdir(parents=True, exist_ok=True)
|
|
(role_path / "vars").mkdir(parents=True, exist_ok=True)
|
|
(role_path / "config").mkdir(parents=True, exist_ok=True)
|
|
|
|
inv_path = tmp / "inventory.yml"
|
|
inv_path.write_text("applications: {}\n", encoding="utf-8")
|
|
|
|
(role_path / ROLE_FILE_VARS_MAIN).write_text(
|
|
'application_id: "web-app-demo"\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
(role_path / ROLE_FILE_META_SERVICES).write_text(
|
|
"mariadb:\n enabled: true\n shared: true\nsso:\n enabled: true\n flavor: oidc\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
(role_path / ROLE_FILE_META_SECRETS).write_text(
|
|
"credentials:\n"
|
|
" api_key:\n"
|
|
" description: API key\n"
|
|
" algorithm: random_hex_16\n"
|
|
" validation: {}\n"
|
|
" plain_needed:\n"
|
|
" description: Needs override\n"
|
|
" algorithm: plain\n"
|
|
" validation: {}\n"
|
|
"non_credentials:\n"
|
|
" flag: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_vault = _FakeVaultHandler("pw")
|
|
|
|
with patch(
|
|
"utils.manager.inventory.VaultHandler",
|
|
side_effect=lambda pw: fake_vault,
|
|
):
|
|
mgr = InventoryManager(
|
|
role_path=role_path,
|
|
inventory_path=inv_path,
|
|
vault_pw="pw",
|
|
overrides={
|
|
"applications.web-app-demo.secrets.credentials.plain_needed": "OVERRIDE"
|
|
},
|
|
allow_empty_plain=False,
|
|
)
|
|
inv = mgr.apply_schema()
|
|
|
|
apps = inv.get("applications", {})
|
|
self.assertIn("web-app-demo", apps)
|
|
self.assertIn("svc-db-mariadb", apps)
|
|
|
|
root_app = apps["web-app-demo"]
|
|
root_creds = root_app["secrets"]["credentials"]
|
|
|
|
self.assertIn("database_password", root_creds)
|
|
self.assertIsInstance(root_creds["database_password"], str)
|
|
self.assertNotIsInstance(root_creds["database_password"], VaultScalar)
|
|
|
|
self.assertIn("sso_proxy_cookie_secret", root_creds)
|
|
self.assertIsInstance(root_creds["sso_proxy_cookie_secret"], str)
|
|
self.assertNotIsInstance(root_creds["sso_proxy_cookie_secret"], VaultScalar)
|
|
|
|
self.assertIn("api_key", root_creds)
|
|
self.assertIsInstance(root_creds["api_key"], VaultScalar)
|
|
self.assertIn("$ANSIBLE_VAULT", str(root_creds["api_key"]))
|
|
|
|
self.assertIn("plain_needed", root_creds)
|
|
self.assertIsInstance(root_creds["plain_needed"], VaultScalar)
|
|
self.assertIn(
|
|
"PLAIN:plain_needed:OVERRIDE", str(root_creds["plain_needed"])
|
|
)
|
|
|
|
self.assertEqual(root_app["secrets"]["non_credentials"]["flag"], True)
|
|
|
|
prov_app = apps["svc-db-mariadb"]
|
|
prov_creds = prov_app["secrets"]["credentials"]
|
|
|
|
self.assertIn("root_password", prov_creds)
|
|
self.assertIsInstance(prov_creds["root_password"], VaultScalar)
|
|
|
|
self.assertIn("replication_password", prov_creds)
|
|
self.assertIsInstance(prov_creds["replication_password"], VaultScalar)
|
|
|
|
called_keys = [k for (_plain, k) in fake_vault.calls]
|
|
|
|
self.assertIn("api_key", called_keys)
|
|
self.assertIn("plain_needed", called_keys)
|
|
self.assertIn("root_password", called_keys)
|
|
self.assertIn("replication_password", called_keys)
|
|
|
|
self.assertNotIn("database_password", called_keys)
|
|
self.assertNotIn("sso_proxy_cookie_secret", called_keys)
|
|
|
|
def test_apply_schema_skips_schema_less_transitive_provider_role(self):
|
|
"""
|
|
Shared provider roles without schema/main.yml must be ignored gracefully.
|
|
This matters for roles like web-svc-asset that participate in dependency
|
|
discovery but do not define credentials.
|
|
"""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp = Path(tmpdir)
|
|
|
|
roles_root = tmp / "roles"
|
|
roles_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
provider_role = roles_root / "web-svc-asset"
|
|
(provider_role / "meta").mkdir(parents=True, exist_ok=True)
|
|
(provider_role / "vars").mkdir(parents=True, exist_ok=True)
|
|
(provider_role / "config").mkdir(parents=True, exist_ok=True)
|
|
|
|
(provider_role / ROLE_FILE_VARS_MAIN).write_text(
|
|
'application_id: "web-svc-asset"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(provider_role / ROLE_FILE_META_SERVICES).write_text(
|
|
"asset:\n enabled: false\n shared: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
role_path = roles_root / "web-app-demo"
|
|
(role_path / "meta").mkdir(parents=True, exist_ok=True)
|
|
(role_path / "vars").mkdir(parents=True, exist_ok=True)
|
|
(role_path / "config").mkdir(parents=True, exist_ok=True)
|
|
|
|
inv_path = tmp / "inventory.yml"
|
|
inv_path.write_text("applications: {}\n", encoding="utf-8")
|
|
|
|
(role_path / ROLE_FILE_VARS_MAIN).write_text(
|
|
'application_id: "web-app-demo"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(role_path / ROLE_FILE_META_SERVICES).write_text(
|
|
"asset:\n enabled: true\n shared: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
(role_path / ROLE_FILE_META_SECRETS).write_text(
|
|
"credentials:\n"
|
|
" api_key:\n"
|
|
" description: API key\n"
|
|
" algorithm: random_hex_16\n"
|
|
" validation: {}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_vault = _FakeVaultHandler("pw")
|
|
with patch(
|
|
"utils.manager.inventory.VaultHandler",
|
|
side_effect=lambda pw: fake_vault,
|
|
):
|
|
mgr = InventoryManager(
|
|
role_path=role_path,
|
|
inventory_path=inv_path,
|
|
vault_pw="pw",
|
|
overrides={},
|
|
allow_empty_plain=False,
|
|
)
|
|
inv = mgr.apply_schema()
|
|
|
|
apps = inv.get("applications", {})
|
|
self.assertIn("web-app-demo", apps)
|
|
self.assertIn("api_key", apps["web-app-demo"]["secrets"]["credentials"])
|
|
self.assertNotIn("web-svc-asset", apps)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|