diff --git a/filter_plugins/merge_with_defaults.py b/filter_plugins/merge_with_defaults.py new file mode 100644 index 00000000..233d0dca --- /dev/null +++ b/filter_plugins/merge_with_defaults.py @@ -0,0 +1,39 @@ +def merge_with_defaults(defaults, customs): + """ + Recursively merge two dicts (customs into defaults). + For each top-level key in customs, ensure all dict keys from defaults are present (at least empty dict). + Customs always take precedence. + """ + def merge_dict(d1, d2): + # Recursively merge d2 into d1, d2 wins + result = dict(d1) if d1 else {} + for k, v in (d2 or {}).items(): + if k in result and isinstance(result[k], dict) and isinstance(v, dict): + result[k] = merge_dict(result[k], v) + else: + result[k] = v + return result + + merged = {} + # Union of all app-keys + all_keys = set(defaults or {}).union(set(customs or {})) + for app_key in all_keys: + base = (defaults or {}).get(app_key, {}) + override = (customs or {}).get(app_key, {}) + + # Step 1: merge override into base + result = merge_dict(base, override) + + # Step 2: ensure all dict keys from base exist in result (at least {}) + for k, v in (base or {}).items(): + if isinstance(v, dict) and k not in result: + result[k] = {} + merged[app_key] = result + return merged + +class FilterModule(object): + '''Custom merge filter for CyMaIS: merge_with_defaults''' + def filters(self): + return { + 'merge_with_defaults': merge_with_defaults, + } diff --git a/roles/web-app-mig/meta/main.yml b/roles/web-app-mig/meta/main.yml index 500e4885..fed7f263 100644 --- a/roles/web-app-mig/meta/main.yml +++ b/roles/web-app-mig/meta/main.yml @@ -19,7 +19,7 @@ galaxy_info: issue_tracker_url: "https://github.com/kevinveenbirkenbach/meta-infinite-graph/issues" documentation: "https://github.com/kevinveenbirkenbach/meta-infinite-graph/" logo: - class: "" + class: "fa-solid fa-infinity" run_after: [] dependencies: - sys-cli diff --git a/tests/unit/filter_plugins/test_merge_with_defaults.py b/tests/unit/filter_plugins/test_merge_with_defaults.py new file mode 100644 index 00000000..79b7d96b --- /dev/null +++ b/tests/unit/filter_plugins/test_merge_with_defaults.py @@ -0,0 +1,107 @@ +import unittest +import sys +import os + +# Allow import from project filter_plugins directory +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))) + +from merge_with_defaults import merge_with_defaults + +class TestMergeWithDefaultsFilter(unittest.TestCase): + + def test_basic_merge(self): + defaults = { + "app1": { + "docker": { + "network": "default", + "services": {"foo": "bar"}, + "volumes": {"data": "/mnt"} + }, + "features": {"ldap": True, "sso": False}, + "version": 1 + } + } + + customs = { + "app1": { + "docker": { + "network": "customnet" + }, + "version": 2 + }, + "app2": { + "docker": { + "network": "other" + } + } + } + + expected = { + "app1": { + "docker": { + "network": "customnet", + "services": {"foo": "bar"}, + "volumes": {"data": "/mnt"} + }, + "features": {"ldap": True, "sso": False}, + "version": 2 + }, + "app2": { + "docker": { + "network": "other" + } + } + } + + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + + def test_keys_from_defaults_only(self): + defaults = { + "foo": {"docker": {"a": 1, "b": 2}, "features": {"x": True}}, + } + customs = { + "foo": {}, + } + expected = { + "foo": {"docker": {"a": 1, "b": 2}, "features": {"x": True}} + } + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + + def test_custom_overrides_nested_dict(self): + defaults = {"x": {"docker": {"bar": 1, "baz": 2}}} + customs = {"x": {"docker": {"bar": 99}}} + expected = {"x": {"docker": {"bar": 99, "baz": 2}}} + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + + def test_only_defaults_present(self): + defaults = {"only": {"value": 1}} + customs = {} + expected = {"only": {"value": 1}} + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + + def test_only_customs_present(self): + defaults = {} + customs = {"x": {"foo": 42}} + expected = {"x": {"foo": 42}} + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + + def test_deep_merge_multiple_levels(self): + defaults = { + "a": {"outer": {"mid": {"inner": 1, "keep": True}}, "plain": "test"} + } + customs = { + "a": {"outer": {"mid": {"inner": 99}}, "plain": "changed"} + } + expected = { + "a": {"outer": {"mid": {"inner": 99, "keep": True}}, "plain": "changed"} + } + result = merge_with_defaults(defaults, customs) + self.assertEqual(result, expected) + +if __name__ == "__main__": + unittest.main()