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

@@ -98,5 +98,66 @@ class TestCreateCredentials(unittest.TestCase):
self.assertIsInstance(creds['api_key'], str)
self.assertTrue(creds['api_key'].lstrip().startswith('$ANSIBLE_VAULT'))
if __name__ == '__main__':
unittest.main()
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 = os.path.join(tmpdir, 'role')
os.makedirs(os.path.join(role_path, 'config'))
os.makedirs(os.path.join(role_path, 'schema'))
os.makedirs(os.path.join(role_path, 'vars'))
# vars/main.yml with application_id
main_vars = {'application_id': 'app_empty_plain'}
with open(os.path.join(role_path, 'vars', 'main.yml'), 'w') as f:
yaml.dump(main_vars, f)
# config/main.yml
config = {'features': {'central_database': False}}
with open(os.path.join(role_path, "config", "main.yml"), 'w') as f:
yaml.dump(config, f)
# schema/main.yml: plain credential *without* overrides
schema = {
'credentials': {
'api_key': {
'description': 'API key',
'algorithm': 'plain',
'validation': {}
}
}
}
with open(os.path.join(role_path, 'schema', 'main.yml'), 'w') as f:
yaml.dump(schema, f)
# Empty inventory file
inventory_file = os.path.join(tmpdir, 'inventory.yml')
with open(inventory_file, 'w') as f:
yaml.dump({}, f)
# Vault password file
vault_pw_file = os.path.join(tmpdir, 'pw.txt')
with open(vault_pw_file, '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 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 = yaml.safe_load(open(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'], "")

View File

@@ -0,0 +1,170 @@
import os
import sys
import tempfile
import unittest
from pathlib import Path
# Make cli module importable (same pattern as test_credentials.py)
dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../../cli')
)
sys.path.insert(0, dir_path)
from cli.create.inventory import ( # type: ignore
merge_inventories,
ensure_host_vars_file,
)
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
class TestCreateInventory(unittest.TestCase):
def test_merge_inventories_adds_host_and_preserves_existing(self):
"""
merge_inventories() must:
- ensure the given host exists in every group of the new inventory,
- keep existing hosts and their variables untouched,
- copy host vars from the new inventory when available,
- create missing groups and add the host.
"""
host = "localhost"
base = {
"all": {
"children": {
"web-app-nextcloud": {
"hosts": {
"oldhost": {"ansible_host": "1.2.3.4"},
}
},
"web-app-matomo": {
"hosts": {
"otherhost": {"ansible_host": "5.6.7.8"},
}
},
}
}
}
# New inventory with localhost defined in two groups
new = {
"all": {
"children": {
"web-app-nextcloud": {
"hosts": {
"localhost": {"ansible_host": "127.0.0.1"},
}
},
"web-app-matomo": {
"hosts": {
"localhost": {},
}
},
# A new group with no hosts → merge_inventories must create hosts + localhost
"web-app-phpmyadmin": {}
}
}
}
merged = merge_inventories(base, new, host=host)
children = merged["all"]["children"]
# 1) Existing hosts must remain unchanged
self.assertIn("oldhost", children["web-app-nextcloud"]["hosts"])
self.assertIn("otherhost", children["web-app-matomo"]["hosts"])
# 2) localhost must be inserted into all groups from `new`
self.assertIn("localhost", children["web-app-nextcloud"]["hosts"])
self.assertIn("localhost", children["web-app-matomo"]["hosts"])
self.assertIn("localhost", children["web-app-phpmyadmin"]["hosts"])
# 3) Host vars from the new inventory must be preserved
self.assertEqual(
children["web-app-nextcloud"]["hosts"]["localhost"],
{"ansible_host": "127.0.0.1"},
)
# Empty dict stays empty
self.assertEqual(
children["web-app-matomo"]["hosts"]["localhost"],
{},
)
# New group with no host vars receives an empty dict
self.assertEqual(
children["web-app-phpmyadmin"]["hosts"]["localhost"],
{},
)
def test_ensure_host_vars_file_preserves_vault_and_adds_defaults(self):
"""
ensure_host_vars_file() must:
- load existing YAML containing a !vault tag without crashing,
- preserve the !vault node including its tag,
- keep existing keys untouched,
- add PRIMARY_DOMAIN, and WEB_PROTOCOL only when missing,
- not overwrite them on subsequent calls.
"""
yaml_rt = YAML(typ="rt")
yaml_rt.preserve_quotes = True
with tempfile.TemporaryDirectory() as tmpdir:
host = "localhost"
host_vars_dir = Path(tmpdir)
host_vars_file = host_vars_dir / f"{host}.yml"
# File containing a !vault tag to ensure ruamel loader works correctly
initial_yaml = """\
secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
ENCRYPTEDVALUE
existing_key: foo
"""
host_vars_dir.mkdir(parents=True, exist_ok=True)
host_vars_file.write_text(initial_yaml, encoding="utf-8")
# Run ensure_host_vars_file
ensure_host_vars_file(
host_vars_file=host_vars_file,
host=host,
primary_domain="example.org",
web_protocol="https",
)
# Reload with ruamel.yaml to verify structure and tags
with host_vars_file.open("r", encoding="utf-8") as f:
data = yaml_rt.load(f)
self.assertIsInstance(data, CommentedMap)
# Existing keys must remain
self.assertIn("secret", data)
self.assertIn("existing_key", data)
self.assertEqual(data["existing_key"], "foo")
# !vault tag must stay intact
secret_node = data["secret"]
self.assertEqual(getattr(secret_node, "tag", None), "!vault")
# Default values must be added
self.assertEqual(data["PRIMARY_DOMAIN"], "example.org")
self.assertEqual(data["WEB_PROTOCOL"], "https")
# A second call must NOT overwrite existing defaults
ensure_host_vars_file(
host_vars_file=host_vars_file,
host="other-host",
primary_domain="other.example",
web_protocol="http",
)
with host_vars_file.open("r", encoding="utf-8") as f:
data2 = yaml_rt.load(f)
# Values remain unchanged
self.assertEqual(data2["PRIMARY_DOMAIN"], "example.org")
self.assertEqual(data2["WEB_PROTOCOL"], "https")
if __name__ == "__main__":
unittest.main()