diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 14aec450c7..3aa8bb8f13 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -29,6 +29,14 @@ attributes: support: full options: + append: + description: + - If V(true), only the keys listed in the O(overrides) will be modified by this module. Any other pre-existing + keys will be ignored. + - If V(false), all locale overrides will be made to match configuration of this module. I.e. any keys + missing from the O(overrides) will be removed regardless of O(state) value. + type: bool + default: true locale: description: - Locale code for which the overrides apply (for example, V(en), V(fi), V(de)). @@ -42,9 +50,13 @@ options: state: description: - Desired state of localization overrides for the given locale. - - On V(present), the set of overrides for the locale will be made to match O(overrides) exactly. - - Keys not listed in O(overrides) will be removed, and the listed keys will be created or updated. - - On V(absent), all overrides for the locale will be removed. + - On V(present), the set of overrides for the locale will be made to match O(overrides). + If O(append) is V(false) keys not listed in O(overrides) will be removed, + and the listed keys will be created or updated. + If O(append) is V(true) keys not listed in O(overrides) will be ignored, + and the listed keys will be created or updated. + - On V(absent), overrides for the locale will be removed. If O(append) is V(false), all keys will be removed. + If O(append) is V(true), only the keys listed in O(overrides) will be removed. type: str choices: ['present', 'absent'] default: present @@ -163,14 +175,16 @@ end_state: value: Bye """ +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule + from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, - keycloak_argument_spec, - get_token, KeycloakError, + get_token, + keycloak_argument_spec, ) -from ansible.module_utils.basic import AnsibleModule -from copy import deepcopy def _normalize_overrides_from_api(current): @@ -212,6 +226,7 @@ def main(): parent_id=dict(type="str", required=True), state=dict(type="str", default="present", choices=["present", "absent"]), overrides=dict(type="list", elements="dict", options=overrides_spec, default=[]), + append=dict(type="bool", default=True), ) argument_spec.update(meta_args) @@ -239,6 +254,7 @@ def main(): locale = module.params.get("locale") state = module.params.get("state") parent_id = module.params.get("parent_id") + append = module.params.get("append") desired_raw = module.params.get("overrides") or [] desired_map = {r["key"]: r.get("value") for r in desired_raw} @@ -291,6 +307,11 @@ def main(): to_update.append(record) result["changed"] = True + # ignore any left-overs in to_remove, append is true + if append: + changeset["overrides"].extend(to_remove) + to_remove = [] + # Any leftovers in to_remove must be deleted if to_remove: result["changed"] = True @@ -325,22 +346,43 @@ def main(): result["end_state"] = {"locale": locale, "overrides": final_overrides} elif state == "absent": - # Full removal of locale overrides + # touch only overrides listed in parameters, leave the rest be + if append: + to_remove = deepcopy(desired_overrides) + to_keep = deepcopy(old_overrides) + + for override in to_remove: + found = False + for keep in to_keep: + if override["key"] == keep["key"]: + to_keep.remove(keep) + found = True + break + + # not present + if not found: + to_remove.remove(override) + + changeset["overrides"] = to_keep + + else: + to_remove = old_overrides + + if to_remove: + result["changed"] = True if module._diff: result["diff"] = dict(before=before, after=changeset) if module.check_mode: - if old_overrides: - result["changed"] = True - result["msg"] = "All overrides for locale %s would be deleted." % (locale) + if result["changed"]: + result["msg"] = "Overrides for locale %s would be deleted." % (locale) else: result["msg"] = "No overrides for locale %s to be deleted." % (locale) else: - for override in old_overrides: + for override in to_remove: kc.delete_localization_value(locale, override["key"], parent_id) - result["changed"] = True result["msg"] = "Locale %s has no overrides." % (locale) diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index fcd3811e14..249a1a56e2 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -124,44 +124,6 @@ class TestKeycloakRealmLocalization(ModuleTestCase): self.assertEqual(mock_del_value.call_count, 0) self.assertIs(exec_info.exception.args[0]["changed"], False) - def test_present_creates_updates_and_deletes(self): - """Create missing, update differing, and delete extra overrides.""" - module_args = { - "auth_keycloak_url": "http://keycloak.url/auth", - "token": "{{ access_token }}", - "parent_id": "my-realm", - "locale": "en", - "state": "present", - "overrides": [ - {"key": "a", "value": "1-new"}, # update - {"key": "c", "value": "3"}, # create - ], - } - # Before: a=1, b=2; After: a=1-new, c=3 - return_value_get_localization_values = [ - {"a": "1", "b": "2"}, - {"a": "1-new", "c": "3"}, - ] - return_value_set = [None, None] - return_value_delete = [None] - - with set_module_args(module_args): - with mock_good_connection(): - with patch_keycloak_api( - get_localization_values=return_value_get_localization_values, - set_localization_value=return_value_set, - delete_localization_value=return_value_delete, - ) as (mock_get_values, mock_set_value, mock_del_value): - with self.assertRaises(AnsibleExitJson) as exec_info: - self.module.main() - - self.assertEqual(mock_get_values.call_count, 2) - # One delete for 'b' - self.assertEqual(mock_del_value.call_count, 1) - # Two set calls: update 'a', create 'c' - self.assertEqual(mock_set_value.call_count, 2) - self.assertIs(exec_info.exception.args[0]["changed"], True) - def test_present_check_mode_only_reports(self): """Check mode: report changes, do not call API mutators.""" module_args = { @@ -197,34 +159,6 @@ class TestKeycloakRealmLocalization(ModuleTestCase): self.assertIs(exec_info.exception.args[0]["changed"], True) self.assertIn("would be updated", exec_info.exception.args[0]["msg"]) - def test_absent_deletes_all(self): - """Remove all overrides when present.""" - module_args = { - "auth_keycloak_url": "http://keycloak.url/auth", - "token": "{{ access_token }}", - "parent_id": "my-realm", - "locale": "en", - "state": "absent", - } - return_value_get_localization_values = [ - {"k1": "v1", "k2": "v2"}, - ] - return_value_delete = [None, None] - - with set_module_args(module_args): - with mock_good_connection(): - with patch_keycloak_api( - get_localization_values=return_value_get_localization_values, - delete_localization_value=return_value_delete, - ) as (mock_get_values, mock_set_value, mock_del_value): - with self.assertRaises(AnsibleExitJson) as exec_info: - self.module.main() - - self.assertEqual(mock_get_values.call_count, 1) - self.assertEqual(mock_del_value.call_count, 2) - self.assertEqual(mock_set_value.call_count, 0) - self.assertIs(exec_info.exception.args[0]["changed"], True) - def test_absent_idempotent_when_nothing_to_delete(self): """No change when locale has no overrides.""" module_args = { @@ -274,6 +208,147 @@ class TestKeycloakRealmLocalization(ModuleTestCase): self.assertIn("missing required arguments: value", exec_info.exception.args[0]["msg"]) + def test_present_append_true_preserves_unspecified_keys(self): + """With append=True, only modify specified keys, preserve others.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "append": True, + "overrides": [ + {"key": "a", "value": "1-updated"}, # update existing + {"key": "c", "value": "3"}, # create new + ], + } + # Before: a=1, b=2; After: a=1-updated, b=2, c=3 (b is preserved) + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + {"a": "1-updated", "b": "2", "c": "3"}, + ] + return_value_set = [None, None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + set_localization_value=return_value_set, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + # No deletes - key 'b' should be preserved + self.assertEqual(mock_del_value.call_count, 0) + # Two set calls: update 'a', create 'c' + self.assertEqual(mock_set_value.call_count, 2) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_present_append_false_removes_unspecified_keys(self): + """With append=False, create new, update existing, and delete unspecified keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "append": False, + "overrides": [ + {"key": "a", "value": "1-updated"}, # update + {"key": "c", "value": "3"}, # create + ], + } + # Before: a=1, b=2; After: a=1-updated, c=3 (b is removed) + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + {"a": "1-updated", "c": "3"}, + ] + return_value_set = [None, None] + return_value_delete = [None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + set_localization_value=return_value_set, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + # One delete for 'b' + self.assertEqual(mock_del_value.call_count, 1) + # Two set calls: update 'a', create 'c' + self.assertEqual(mock_set_value.call_count, 2) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_absent_append_true_removes_only_specified_keys(self): + """With state=absent and append=True, remove only specified keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", + "append": True, + "overrides": [ + {"key": "a"}, + ], + } + # Before: a=1, b=2; Remove only 'a', keep 'b' + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + ] + return_value_delete = [None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + # One delete for 'a' only + self.assertEqual(mock_del_value.call_count, 1) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], True) + + def test_absent_append_false_removes_all_keys(self): + """With state=absent and append=False, remove all keys.""" + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", + "append": False, + } + # Before: a=1, b=2; Remove all + return_value_get_localization_values = [ + {"a": "1", "b": "2"}, + ] + return_value_delete = [None, None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + # Two deletes for 'a' and 'b' + self.assertEqual(mock_del_value.call_count, 2) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]["changed"], True) + if __name__ == "__main__": unittest.main()