From ead0e2b6bf9510c88dd9adf2ccda8920a5b6bf50 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 26 Aug 2025 12:03:44 +0200 Subject: [PATCH 01/33] add support for management of keycloak localizations --- .../identity/keycloak/keycloak.py | 78 ++++ .../modules/keycloak_realm_localization.py | 375 ++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 plugins/modules/keycloak_realm_localization.py diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 1482f9693b..008f9af8f7 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -25,6 +25,9 @@ URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" +URL_LOCALIZATIONS = "{url}/admin/realms/{realm}/localization/{locale}" +URL_LOCALIZATION = "{url}/admin/realms/{realm}/localization/{locale}/{key}" + URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" @@ -590,6 +593,81 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc()) +def get_localization_values(self, locale, realm="master"): + """ + Get all localization overrides for a given realm and locale. + + Parameters: + locale (str): Locale code (for example, 'en', 'fi', 'de'). + realm (str): Realm name. Defaults to 'master'. + + Returns: + dict[str, str]: Mapping of localization keys to override values. + + Raises: + KeycloakError: Wrapped HTTP/JSON error with context via fail_open_url(). + """ + realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) + + try: + return json.loads(to_native(open_url(realm_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not read localization overrides for realm %s, locale %s: %s' % (realm, locale, str(e)), + exception=traceback.format_exc()) + +def set_localization_value(self, locale, key, value, realm="master"): + """ + Create or update a single localization override for the given key. + + Parameters: + locale (str): Locale code (for example, 'en'). + key (str): Localization message key to set. + value (str): Override value to set. + realm (str): Realm name. Defaults to 'master'. + + Returns: + HTTPResponse: Response object on success. + + Raises: + KeycloakError: Wrapped HTTP error with context via fail_open_url(). + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + headers = self.restheaders.copy() + headers['Content-Type'] = 'text/plain; charset=utf-8' + + try: + return open_url(realm_url, method='PUT', http_agent=self.http_agent, headers=headers, timeout=self.connection_timeout, + data=to_native(value), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not set localization value in realm %s, locale %s: %s=%s: %s' % (realm, locale, key, value, str(e)), + exception=traceback.format_exc()) + +def delete_localization_value(self, locale, key, realm="master"): + """ + Delete a single localization override key for the given locale. + + Parameters: + locale (str): Locale code (for example, 'en'). + key (str): Localization message key to delete. + realm (str): Realm name. Defaults to 'master'. + + Returns: + HTTPResponse: Response object on success. + + Raises: + KeycloakError: Wrapped HTTP error with context via fail_open_url(). + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + try: + return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not delete localization value in realm %s, locale %s, key %s: %s' % (realm, locale, key, str(e)), + exception=traceback.format_exc()) + def get_clients(self, realm: str = "master", filter=None): """Obtains client representations for clients in a realm diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py new file mode 100644 index 0000000000..d963fc56a9 --- /dev/null +++ b/plugins/modules/keycloak_realm_localization.py @@ -0,0 +1,375 @@ +# Python +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# This Ansible module manages realm localization overrides in Keycloak. +# It ensures the set of message key/value overrides for a given locale +# either matches the provided list (state=present) or is fully removed (state=absent). + +# Copyright (c) 2025, Jakub Danek +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_realm_localization + +short_description: Manage Keycloak realm localization overrides via the Keycloak API + +version_added: 10.5.0 + +description: + - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. + Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - The names of module options are snake_cased versions of the names found in the Keycloak API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + locale: + description: + - Locale code for which the overrides apply (for example, C(en), C(fi), C(de)). + type: str + required: true + parent_id: + description: + - Name of the realm that owns the locale overrides. + type: str + required: true + state: + description: + - Desired state of localization overrides for the given locale. + - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly: + keys not listed in C(overrides) will be removed, and listed keys will be created or updated. + - On C(absent), all overrides for the locale will be removed. + type: str + choices: [present, absent] + default: present + overrides: + description: + - List of overrides to ensure for the locale when C(state=present). Each item is a mapping with + the message C(key) and its C(value). + - Ignored when C(state=absent). + type: list + elements: dict + default: [] + suboptions: + key: + description: + - The message key to override. + type: str + required: true + value: + description: + - The override value for the message key. + type: str + required: true + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Jakub Danek (@danekja) +''' + +EXAMPLES = ''' +- name: Replace all overrides for locale "en" (credentials auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + overrides: + - key: greeting + value: "Hello" + - key: farewell + value: "Bye" + delegate_to: localhost + +- name: Ensure only one override exists for locale "fi" (token auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + parent_id: my-realm + locale: fi + state: present + overrides: + - key: app.title + value: "Sovellukseni" + delegate_to: localhost + +- name: Remove all overrides for locale "de" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: de + state: absent + delegate_to: localhost + +- name: Dry run: see what would change for locale "en" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + overrides: + - key: greeting + value: "Hello again" + check_mode: true + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Human-readable message about what action was taken. + returned: always + type: str +end_state: + description: + - Final state of localization overrides for the locale after module execution. + - Contains the C(locale) and the list of C(overrides) as key/value items. + returned: on success + type: dict + contains: + locale: + description: The locale code affected. + type: str + sample: en + overrides: + description: The list of overrides that exist after execution. + type: list + elements: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye +diff: + description: + - When run with C(--diff), shows the before and after structures + for the locale and its overrides. + returned: when supported and requested + type: dict + contains: + before: + type: dict + after: + type: dict +''' + + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +from copy import deepcopy + +def _normalize_overrides_from_api(current): + """ + Accept either: + - dict: {'k1': 'v1', ...} + - list of dicts: [{'key': 'k1', 'value': 'v1'}, ...] + Return a sorted list of {'key', 'value'}. + + This helper provides a consistent shape for downstream comparison/diff logic. + """ + if not current: + return [] + + if isinstance(current, dict): + # Convert mapping to list of key/value dicts + items = [{'key': k, 'value': v} for k, v in current.items()] + else: + # Assume a list of dicts with keys 'key' and 'value' + items = [{'key': o['key'], 'value': o.get('value')} for o in current] + + # Sort for stable comparisons and diff output + return sorted(items, key=lambda x: x['key']) + + +def main(): + """ + Module execution + + :return: + """ + # Base Keycloak auth/spec fragment common across Keycloak modules + argument_spec = keycloak_argument_spec() + + # Describe a single override record + overrides_spec = dict( + key=dict(type='str', required=True), + value=dict(type='str', required=True), + ) + + # Module-specific arguments + meta_args = dict( + locale=dict(type='str', required=True), + 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=[]), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + # Require token OR full credential set. This mirrors other Keycloak modules. + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + # Initialize the result object used by Ansible + result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience locals for frequently used parameters + locale = module.params.get('locale') + state = module.params.get('state') + parent_id = module.params.get('parent_id') + + # Desired overrides: deduplicate by key via dict (last wins), then sort + desired_raw = module.params.get('overrides') or [] + if state == 'present': + # Validate that all keys have a value in present mode + missing_values = [r['key'] for r in desired_raw if r.get('value') is None] + if missing_values: + module.fail_json(msg="state=present requires 'value' for keys: %s" % ", ".join(missing_values)) + + desired_map = {r['key']: r.get('value') for r in desired_raw} + desired_overrides = [{'key': k, 'value': v} for k, v in sorted(desired_map.items())] + + # Fetch current overrides and normalize to comparable structure + old_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + before = { + 'locale': locale, + 'overrides': deepcopy(old_overrides), + } + + # Proposed state used for diff reporting + changeset = { + 'locale': locale, + 'overrides': [], + } + + # Default to no change; flip to True when updates/deletes are needed + result['changed'] = False + + if state == 'present': + + changeset['overrides'] = deepcopy(desired_overrides) + + # Compute two sets: + # - to_update: keys missing or with different values + # - to_remove: keys existing in current state but not in desired + to_update = [] + to_remove = old_overrides.copy() + + # Mark updates and remove matched ones from to_remove + for record in desired_overrides: + override_found = False + + for override in to_remove: + + if override['key'] == record['key']: + override_found = True + + # Value differs -> update needed + if override['value'] != record['value']: + result['changed'] = True + to_update.append(record) + + # Remove processed item so what's left in to_remove are deletions + to_remove.remove(override) + break + + if not override_found: + # New key, must be created + to_update.append(record) + result['changed'] = True + + # Any leftovers in to_remove must be deleted + if len(to_remove) > 0: + result['changed'] = True + + if result['changed']: + if module._diff: + result['diff'] = dict(before=before, after=changeset) + + if module.check_mode: + # Dry-run: report intent without side effects + result['msg'] = "Locale %s overrides would be updated." % (locale) + + else: + + for override in to_remove: + kc.delete_localization_value(locale, override['key'], parent_id) + + for override in to_update: + kc.set_localization_value(locale, override['key'], override['value'], parent_id) + + result['msg'] = "Locale %s overrides have been updated." % (locale) + + else: + result['msg'] = "Locale %s overrides are in sync." % (locale) + + # For accurate end_state, read back from API unless we are in check_mode + final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) if not module.check_mode else changeset['overrides'] + result['end_state'] = {'locale': locale, 'overrides': final_overrides} + + elif state == 'absent': + # Full removal of locale overrides + + if module._diff: + result['diff'] = dict(before=before, after=changeset) + + if module.check_mode: + + if len(old_overrides) > 0: + result['changed'] = True + result['msg'] = "All 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: + kc.delete_localization_value(locale, override['key'], parent_id) + result['changed'] = True + + result['msg'] = "Locale %s has no overrides." % (locale) + + result['end_state'] = changeset + + + module.exit_json(**result) + + +if __name__ == '__main__': + main() From 395295e9dea1656ee647e49ed9a97e0dfc0c0418 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 19 Sep 2025 12:55:13 +0200 Subject: [PATCH 02/33] unit test for keycloak localization support --- .../test_keycloak_realm_localization.py | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/unit/plugins/modules/test_keycloak_realm_localization.py diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py new file mode 100644 index 0000000000..6849a84fbd --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -0,0 +1,262 @@ +# Python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from contextlib import contextmanager + +from ansible_collections.community.internal_test_tools.tests.unit.compat import unittest +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleTestCase, + set_module_args, +) + +from ansible_collections.community.general.plugins.modules import keycloak_realm_localization + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_localization_values=None, set_localization_value=None, delete_localization_value=None): + """ + Patch KeycloakAPI methods used by the module under test. + """ + obj = keycloak_realm_localization.KeycloakAPI + with patch.object(obj, 'get_localization_values', side_effect=get_localization_values) as mock_get_values: + with patch.object(obj, 'set_localization_value', side_effect=set_localization_value) as mock_set_value: + with patch.object(obj, 'delete_localization_value', side_effect=delete_localization_value) as mock_del_value: + yield mock_get_values, mock_set_value, mock_del_value + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response( + object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + def _create_wrapper(): + return StringIO(text_as_string) + return _create_wrapper + + +def mock_good_connection(): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), + } + return patch( + 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +class TestKeycloakRealmLocalization(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmLocalization, self).setUp() + self.module = keycloak_realm_localization + + def test_present_no_change_in_sync(self): + """Desired overrides already match, no change.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'greeting', 'value': 'Hello'}, + {'key': 'farewell', 'value': 'Bye'}, + ], + } + # get_localization_values is called twice: before and after + return_value_get_localization_values = [ + {'greeting': 'Hello', 'farewell': 'Bye'}, + {'greeting': 'Hello', 'farewell': 'Bye'}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + 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) + self.assertEqual(mock_set_value.call_count, 0) + 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 = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'x', 'value': '1'}, # change + {'key': 'y', 'value': '2'}, # create + ], + '_ansible_check_mode': True, # signal for readers; set_module_args is what matters + } + return_value_get_localization_values = [ + {'x': '0'}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Only read current values + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_set_value.call_count, 0) + self.assertEqual(mock_del_value.call_count, 0) + 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 = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'absent', + } + return_value_get_localization_values = [ + {}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + 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, 0) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]['changed'], False) + + def test_present_missing_value_validation(self): + """Validation error when state=present and value is missing.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'greeting', 'value': None}, + ], + } + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api() \ + as (_mock_get_values, _mock_set_value, _mock_del_value): + with self.assertRaises(AnsibleFailJson) as exec_info: + self.module.main() + + self.assertIn("requires 'value' for keys", exec_info.exception.args[0]['msg']) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 1df29cdf9d7693a2982eb73ae0e07bd74f0e42b6 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 19 Sep 2025 13:27:32 +0200 Subject: [PATCH 03/33] keycloak_realm_localization botmeta record --- .github/BOTMETA.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 9066eaa4f5..16f61156d2 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -857,6 +857,8 @@ files: maintainers: fynncfchen $modules/keycloak_realm_key.py: maintainers: mattock + $modules/keycloak_realm_localization.py: + maintainers: danekja $modules/keycloak_role.py: maintainers: laurpaum $modules/keycloak_user.py: From 76586aa0dc9781b4a8a35f0956422bfb94b41a10 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 19 Sep 2025 13:55:49 +0200 Subject: [PATCH 04/33] update implementation to latest version of community.general --- .../identity/keycloak/keycloak.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 008f9af8f7..3616e243c9 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -389,7 +389,7 @@ class KeycloakAPI: self.restheaders = connection_header self.http_agent = self.module.params.get("http_agent") - def _request(self, url: str, method: str, data: str | bytes | None = None): + def _request(self, url: str, method: str, data: str | bytes | None = None, headers = None): """Makes a request to Keycloak and returns the raw response. If a 401 is returned, attempts to re-authenticate using first the module's refresh_token (if provided) @@ -400,17 +400,21 @@ class KeycloakAPI: :param url: request path :param method: request method (e.g., 'GET', 'POST', etc.) :param data: (optional) data for request + :param headers headers to be sent with request, defaults to self.restheaders :return: raw API response """ def make_request_catching_401() -> object | HTTPError: try: + if headers is None: + headers = self.restheaders + return open_url( url, method=method, data=data, http_agent=self.http_agent, - headers=self.restheaders, + headers=headers, timeout=self.connection_timeout, validate_certs=self.validate_certs, ) @@ -604,17 +608,16 @@ def get_localization_values(self, locale, realm="master"): Returns: dict[str, str]: Mapping of localization keys to override values. - Raises: - KeycloakError: Wrapped HTTP/JSON error with context via fail_open_url(). - """ - realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) + Raises: + KeycloakError: Wrapped HTTP/JSON error with context + """ + realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) - try: - return json.loads(to_native(open_url(realm_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) - except Exception as e: - self.fail_open_url(e, msg='Could not read localization overrides for realm %s, locale %s: %s' % (realm, locale, str(e)), - exception=traceback.format_exc()) + try: + return self._request_and_deserialize(realm_url, method='GET') + except Exception as e: + self.fail_request(e, msg='Could not read localization overrides for realm %s, locale %s: %s' % (realm, locale, str(e)), + exception=traceback.format_exc()) def set_localization_value(self, locale, key, value, realm="master"): """ @@ -629,20 +632,19 @@ def set_localization_value(self, locale, key, value, realm="master"): Returns: HTTPResponse: Response object on success. - Raises: - KeycloakError: Wrapped HTTP error with context via fail_open_url(). - """ - realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + Raises: + KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) headers = self.restheaders.copy() headers['Content-Type'] = 'text/plain; charset=utf-8' - try: - return open_url(realm_url, method='PUT', http_agent=self.http_agent, headers=headers, timeout=self.connection_timeout, - data=to_native(value), validate_certs=self.validate_certs) - except Exception as e: - self.fail_open_url(e, msg='Could not set localization value in realm %s, locale %s: %s=%s: %s' % (realm, locale, key, value, str(e)), - exception=traceback.format_exc()) + try: + return self._request(realm_url, method='PUT', data=to_native(value), headers=headers) + except Exception as e: + self.fail_request(e, msg='Could not set localization value in realm %s, locale %s: %s=%s: %s' % (realm, locale, key, value, str(e)), + exception=traceback.format_exc()) def delete_localization_value(self, locale, key, realm="master"): """ @@ -656,17 +658,16 @@ def delete_localization_value(self, locale, key, realm="master"): Returns: HTTPResponse: Response object on success. - Raises: - KeycloakError: Wrapped HTTP error with context via fail_open_url(). - """ - realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + Raises: + KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) - try: - return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) - except Exception as e: - self.fail_open_url(e, msg='Could not delete localization value in realm %s, locale %s, key %s: %s' % (realm, locale, key, str(e)), - exception=traceback.format_exc()) + try: + return self._request(realm_url, method='DELETE') + except Exception as e: + self.fail_request(e, msg='Could not delete localization value in realm %s, locale %s, key %s: %s' % (realm, locale, key, str(e)), + exception=traceback.format_exc()) def get_clients(self, realm: str = "master", filter=None): """Obtains client representations for clients in a realm From e2a32861aafbc551f237f95cb265e20cc39bdc2d Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 19 Sep 2025 13:56:25 +0200 Subject: [PATCH 05/33] changelog for module_utils/identity/keycloak - KeycloakAPI._request signature change --- .../fragments/10841-keycloak-add-optional-headers-request.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/10841-keycloak-add-optional-headers-request.yml diff --git a/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml b/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml new file mode 100644 index 0000000000..356c266cb3 --- /dev/null +++ b/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak - adds optional headers parameter override for _request method in module_utils/identity/keycloak (https://github.com/ansible-collections/community.general/pull/10841). \ No newline at end of file From c2412f2fe5a618699c83ee498c6c965c4618c15c Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 14:58:41 +0200 Subject: [PATCH 06/33] fix indentation, documentation and other sanity test findings --- meta/runtime.yml | 1 + .../identity/keycloak/keycloak.py | 6 +-- .../modules/keycloak_realm_localization.py | 52 ++++++++++++------- .../test_keycloak_realm_localization.py | 2 +- 4 files changed, 39 insertions(+), 22 deletions(-) diff --git a/meta/runtime.yml b/meta/runtime.yml index 2efbf3ff89..bfb7dce831 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -40,6 +40,7 @@ action_groups: - keycloak_realm - keycloak_realm_key - keycloak_realm_keys_metadata_info + - keycloak_realm_localization - keycloak_realm_rolemapping - keycloak_role - keycloak_user diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 3616e243c9..495c0e52ea 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -617,7 +617,7 @@ def get_localization_values(self, locale, realm="master"): return self._request_and_deserialize(realm_url, method='GET') except Exception as e: self.fail_request(e, msg='Could not read localization overrides for realm %s, locale %s: %s' % (realm, locale, str(e)), - exception=traceback.format_exc()) + exception=traceback.format_exc()) def set_localization_value(self, locale, key, value, realm="master"): """ @@ -644,7 +644,7 @@ def set_localization_value(self, locale, key, value, realm="master"): return self._request(realm_url, method='PUT', data=to_native(value), headers=headers) except Exception as e: self.fail_request(e, msg='Could not set localization value in realm %s, locale %s: %s=%s: %s' % (realm, locale, key, value, str(e)), - exception=traceback.format_exc()) + exception=traceback.format_exc()) def delete_localization_value(self, locale, key, realm="master"): """ @@ -667,7 +667,7 @@ def delete_localization_value(self, locale, key, realm="master"): return self._request(realm_url, method='DELETE') except Exception as e: self.fail_request(e, msg='Could not delete localization value in realm %s, locale %s, key %s: %s' % (realm, locale, key, str(e)), - exception=traceback.format_exc()) + exception=traceback.format_exc()) def get_clients(self, realm: str = "master", filter=None): """Obtains client representations for clients in a realm diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index d963fc56a9..c2179ebff0 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -1,5 +1,5 @@ # Python -#!/usr/bin/python +# !/usr/bin/python # -*- coding: utf-8 -*- # This Ansible module manages realm localization overrides in Keycloak. @@ -13,8 +13,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -DOCUMENTATION = ''' ---- +DOCUMENTATION = r""" module: keycloak_realm_localization short_description: Manage Keycloak realm localization overrides via the Keycloak API @@ -23,7 +22,7 @@ version_added: 10.5.0 description: - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. - The names of module options are snake_cased versions of the names found in the Keycloak API. attributes: @@ -46,16 +45,16 @@ options: state: description: - Desired state of localization overrides for the given locale. - - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly: - keys not listed in C(overrides) will be removed, and listed keys will be created or updated. + - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly + - keys not listed in C(overrides) will be removed, and listed keys will be created or updated. - On C(absent), all overrides for the locale will be removed. type: str - choices: [present, absent] + choices: ['present', 'absent'] default: present overrides: description: - List of overrides to ensure for the locale when C(state=present). Each item is a mapping with - the message C(key) and its C(value). + - the message C(key) and its C(value). - Ignored when C(state=absent). type: list elements: dict @@ -74,13 +73,14 @@ options: extends_documentation_fragment: - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak - community.general.attributes author: - Jakub Danek (@danekja) -''' +""" -EXAMPLES = ''' +EXAMPLES = r""" - name: Replace all overrides for locale "en" (credentials auth) community.general.keycloak_realm_localization: auth_client_id: admin-cli @@ -123,7 +123,7 @@ EXAMPLES = ''' state: absent delegate_to: localhost -- name: Dry run: see what would change for locale "en" +- name: Dry run - see what would change for locale "en" community.general.keycloak_realm_localization: auth_client_id: admin-cli auth_keycloak_url: https://auth.example.com/auth @@ -138,9 +138,9 @@ EXAMPLES = ''' value: "Hello again" check_mode: true delegate_to: localhost -''' +""" -RETURN = ''' +RETURN = r""" msg: description: Human-readable message about what action was taken. returned: always @@ -173,17 +173,29 @@ diff: type: dict contains: before: + description: State of localization overrides before execution type: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye after: + description: State of localization overrides after execution type: dict -''' - + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye +""" from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule from copy import deepcopy + def _normalize_overrides_from_api(current): """ Accept either: @@ -218,7 +230,7 @@ def main(): # Describe a single override record overrides_spec = dict( - key=dict(type='str', required=True), + key=dict(type='str', no_log=False, required=True), value=dict(type='str', required=True), ) @@ -340,7 +352,12 @@ def main(): result['msg'] = "Locale %s overrides are in sync." % (locale) # For accurate end_state, read back from API unless we are in check_mode - final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) if not module.check_mode else changeset['overrides'] + if not module.check_mode: + final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + + else: + final_overrides = ['overrides'] + result['end_state'] = {'locale': locale, 'overrides': final_overrides} elif state == 'absent': @@ -367,7 +384,6 @@ def main(): result['end_state'] = changeset - module.exit_json(**result) diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index 6849a84fbd..5ff4abe05c 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -259,4 +259,4 @@ class TestKeycloakRealmLocalization(ModuleTestCase): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From c86ee26e36034f386214a495b79c9c5d0c5083aa Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 15:29:44 +0200 Subject: [PATCH 07/33] fix test_keycloak_realm_localization.py for run with newer ansible --- .../plugins/modules/test_keycloak_realm_localization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index 5ff4abe05c..7814d088f4 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -19,7 +19,7 @@ from ansible_collections.community.general.plugins.modules import keycloak_realm from itertools import count -from ansible.module_utils.six import StringIO +from io import StringIO @contextmanager @@ -244,7 +244,7 @@ class TestKeycloakRealmLocalization(ModuleTestCase): 'locale': 'en', 'state': 'present', 'overrides': [ - {'key': 'greeting', 'value': None}, + {'key': 'greeting'}, ], } @@ -255,7 +255,7 @@ class TestKeycloakRealmLocalization(ModuleTestCase): with self.assertRaises(AnsibleFailJson) as exec_info: self.module.main() - self.assertIn("requires 'value' for keys", exec_info.exception.args[0]['msg']) + self.assertIn("missing required arguments: value", exec_info.exception.args[0]['msg']) if __name__ == '__main__': From 1b5d009ddb54d1c48afebe46141cb4ebf7574f62 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 16:04:31 +0200 Subject: [PATCH 08/33] fix closure of _request optional http headers parameter --- plugins/module_utils/identity/keycloak/keycloak.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 495c0e52ea..f35064600c 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -404,11 +404,8 @@ class KeycloakAPI: :return: raw API response """ - def make_request_catching_401() -> object | HTTPError: + def make_request_catching_401(headers) -> object | HTTPError: try: - if headers is None: - headers = self.restheaders - return open_url( url, method=method, @@ -423,7 +420,10 @@ class KeycloakAPI: raise e return e - r = make_request_catching_401() + if headers is None: + headers = self.restheaders + + r = make_request_catching_401(headers) if isinstance(r, Exception): # Try to refresh token and retry, if available From 7b2c6b9aa3c68bad2ffdddbd776f19bf88266b9f Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 16:08:44 +0200 Subject: [PATCH 09/33] add copyright and license to test_keycloak_realm_localization.py --- .../unit/plugins/modules/test_keycloak_realm_localization.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index 7814d088f4..b0fca33d1e 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -1,6 +1,11 @@ # Python # -*- coding: utf-8 -*- +# Copyright (c) 2025, Jakub Danek +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + from __future__ import absolute_import, division, print_function __metaclass__ = type From be75bc9259bc1c477f9746167bc9e710296d0aab Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 16:17:02 +0200 Subject: [PATCH 10/33] fix documentation indentation --- .../modules/keycloak_realm_localization.py | 174 +++++++++--------- 1 file changed, 87 insertions(+), 87 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index c2179ebff0..8a48f5db08 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -21,63 +21,63 @@ short_description: Manage Keycloak realm localization overrides via the Keycloak version_added: 10.5.0 description: - - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. - - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. - - The names of module options are snake_cased versions of the names found in the Keycloak API. + - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. + - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - The names of module options are snake_cased versions of the names found in the Keycloak API. attributes: - check_mode: - support: full - diff_mode: - support: full + check_mode: + support: full + diff_mode: + support: full options: - locale: + locale: + description: + - Locale code for which the overrides apply (for example, C(en), C(fi), C(de)). + type: str + required: true + parent_id: + description: + - Name of the realm that owns the locale overrides. + type: str + required: true + state: + description: + - Desired state of localization overrides for the given locale. + - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly + - keys not listed in C(overrides) will be removed, and listed keys will be created or updated. + - On C(absent), all overrides for the locale will be removed. + type: str + choices: ['present', 'absent'] + default: present + overrides: + description: + - List of overrides to ensure for the locale when C(state=present). Each item is a mapping with + - the message C(key) and its C(value). + - Ignored when C(state=absent). + type: list + elements: dict + default: [] + suboptions: + key: description: - - Locale code for which the overrides apply (for example, C(en), C(fi), C(de)). + - The message key to override. type: str required: true - parent_id: + value: description: - - Name of the realm that owns the locale overrides. + - The override value for the message key. type: str required: true - state: - description: - - Desired state of localization overrides for the given locale. - - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly - - keys not listed in C(overrides) will be removed, and listed keys will be created or updated. - - On C(absent), all overrides for the locale will be removed. - type: str - choices: ['present', 'absent'] - default: present - overrides: - description: - - List of overrides to ensure for the locale when C(state=present). Each item is a mapping with - - the message C(key) and its C(value). - - Ignored when C(state=absent). - type: list - elements: dict - default: [] - suboptions: - key: - description: - - The message key to override. - type: str - required: true - value: - description: - - The override value for the message key. - type: str - required: true extends_documentation_fragment: - - community.general.keycloak - - community.general.keycloak.actiongroup_keycloak - - community.general.attributes + - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak + - community.general.attributes author: - - Jakub Danek (@danekja) + - Jakub Danek (@danekja) """ EXAMPLES = r""" @@ -142,52 +142,52 @@ EXAMPLES = r""" RETURN = r""" msg: - description: Human-readable message about what action was taken. - returned: always - type: str + description: Human-readable message about what action was taken. + returned: always + type: str end_state: - description: - - Final state of localization overrides for the locale after module execution. - - Contains the C(locale) and the list of C(overrides) as key/value items. - returned: on success - type: dict - contains: - locale: - description: The locale code affected. - type: str - sample: en - overrides: - description: The list of overrides that exist after execution. - type: list - elements: dict - sample: - - key: greeting - value: Hello - - key: farewell - value: Bye + description: + - Final state of localization overrides for the locale after module execution. + - Contains the C(locale) and the list of C(overrides) as key/value items. + returned: on success + type: dict + contains: + locale: + description: The locale code affected. + type: str + sample: en + overrides: + description: The list of overrides that exist after execution. + type: list + elements: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye diff: - description: - - When run with C(--diff), shows the before and after structures - for the locale and its overrides. - returned: when supported and requested - type: dict - contains: - before: - description: State of localization overrides before execution - type: dict - sample: - - key: greeting - value: Hello - - key: farewell - value: Bye - after: - description: State of localization overrides after execution - type: dict - sample: - - key: greeting - value: Hello - - key: farewell - value: Bye + description: + - When run with C(--diff), shows the before and after structures + for the locale and its overrides. + returned: when supported and requested + type: dict + contains: + before: + description: State of localization overrides before execution + type: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye + after: + description: State of localization overrides after execution + type: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye """ from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ From 28005c10cbca097f24ac89a9ff33b16e058fa2da Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 16:18:50 +0200 Subject: [PATCH 11/33] replace list.copy with 2.x compatible syntax --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 8a48f5db08..b3dfdf2b5a 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -301,7 +301,7 @@ def main(): # - to_update: keys missing or with different values # - to_remove: keys existing in current state but not in desired to_update = [] - to_remove = old_overrides.copy() + to_remove = list(old_overrides) # Mark updates and remove matched ones from to_remove for record in desired_overrides: From 99348b5be9c6575261cd1a98834ab51d84af46fb Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Thu, 25 Sep 2025 16:19:27 +0200 Subject: [PATCH 12/33] replace list.copy with deepcopy --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index b3dfdf2b5a..d4dbb8dd4c 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -301,7 +301,7 @@ def main(): # - to_update: keys missing or with different values # - to_remove: keys existing in current state but not in desired to_update = [] - to_remove = list(old_overrides) + to_remove = deepcopy(old_overrides) # Mark updates and remove matched ones from to_remove for record in desired_overrides: From 0640f16fc33fa9e75558de0e61bc858b405923f4 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 08:41:32 +0200 Subject: [PATCH 13/33] Update plugins/modules/keycloak_realm_localization.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index d4dbb8dd4c..55c2e5a47e 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -18,7 +18,7 @@ module: keycloak_realm_localization short_description: Manage Keycloak realm localization overrides via the Keycloak API -version_added: 10.5.0 +version_added: 11.4.0 description: - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. From 8c80580c2cec49f0f2ff6330372957a0f7300bdc Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 08:41:01 +0200 Subject: [PATCH 14/33] rev: remove uncessary changelog fragment --- .../fragments/10841-keycloak-add-optional-headers-request.yml | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 changelogs/fragments/10841-keycloak-add-optional-headers-request.yml diff --git a/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml b/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml deleted file mode 100644 index 356c266cb3..0000000000 --- a/changelogs/fragments/10841-keycloak-add-optional-headers-request.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - keycloak - adds optional headers parameter override for _request method in module_utils/identity/keycloak (https://github.com/ansible-collections/community.general/pull/10841). \ No newline at end of file From ca87735e4687dc139c29550da72e243990709c1e Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 08:41:15 +0200 Subject: [PATCH 15/33] rev: remove uncessary code remnants --- .../modules/keycloak_realm_localization.py | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 55c2e5a47e..86db6e7ff8 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -198,9 +198,8 @@ from copy import deepcopy def _normalize_overrides_from_api(current): """ - Accept either: + Accepts: - dict: {'k1': 'v1', ...} - - list of dicts: [{'key': 'k1', 'value': 'v1'}, ...] Return a sorted list of {'key', 'value'}. This helper provides a consistent shape for downstream comparison/diff logic. @@ -208,12 +207,8 @@ def _normalize_overrides_from_api(current): if not current: return [] - if isinstance(current, dict): - # Convert mapping to list of key/value dicts - items = [{'key': k, 'value': v} for k, v in current.items()] - else: - # Assume a list of dicts with keys 'key' and 'value' - items = [{'key': o['key'], 'value': o.get('value')} for o in current] + # Convert mapping to list of key/value dicts + items = [{'key': k, 'value': v} for k, v in current.items()] # Sort for stable comparisons and diff output return sorted(items, key=lambda x: x['key']) @@ -266,14 +261,7 @@ def main(): state = module.params.get('state') parent_id = module.params.get('parent_id') - # Desired overrides: deduplicate by key via dict (last wins), then sort desired_raw = module.params.get('overrides') or [] - if state == 'present': - # Validate that all keys have a value in present mode - missing_values = [r['key'] for r in desired_raw if r.get('value') is None] - if missing_values: - module.fail_json(msg="state=present requires 'value' for keys: %s" % ", ".join(missing_values)) - desired_map = {r['key']: r.get('value') for r in desired_raw} desired_overrides = [{'key': k, 'value': v} for k, v in sorted(desired_map.items())] @@ -327,7 +315,7 @@ def main(): result['changed'] = True # Any leftovers in to_remove must be deleted - if len(to_remove) > 0: + if to_remove: result['changed'] = True if result['changed']: From f5d888ef47dd52cdeb4935903d7b33f92e322e0d Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:23:06 +0200 Subject: [PATCH 16/33] rev: update file header with copyright according to guidelines --- plugins/modules/keycloak_realm_localization.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 86db6e7ff8..3614c9e229 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -2,11 +2,7 @@ # !/usr/bin/python # -*- coding: utf-8 -*- -# This Ansible module manages realm localization overrides in Keycloak. -# It ensures the set of message key/value overrides for a given locale -# either matches the provided list (state=present) or is fully removed (state=absent). - -# Copyright (c) 2025, Jakub Danek +# Copyright: Contributors to the Ansible project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later From cf305733a2c99972f9630ceca232651fb1aa7418 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:23:22 +0200 Subject: [PATCH 17/33] rev: update documentation according to guidelines --- .../modules/keycloak_realm_localization.py | 50 +++++-------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 3614c9e229..b4e01c95e2 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -30,7 +30,7 @@ attributes: options: locale: description: - - Locale code for which the overrides apply (for example, C(en), C(fi), C(de)). + - Locale code for which the overrides apply (for example, V(en), V(fi), V(de)). type: str required: true parent_id: @@ -41,17 +41,17 @@ options: state: description: - Desired state of localization overrides for the given locale. - - On C(present), the set of overrides for the locale will be made to match C(overrides) exactly - - keys not listed in C(overrides) will be removed, and listed keys will be created or updated. - - On C(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) 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. type: str choices: ['present', 'absent'] default: present overrides: description: - - List of overrides to ensure for the locale when C(state=present). Each item is a mapping with - - the message C(key) and its C(value). - - Ignored when C(state=absent). + - List of overrides to ensure for the locale when O(state=present). Each item is a mapping with + the record's O(overrides.key) and its O(overrides.value). + - Ignored when O(state=absent). type: list elements: dict default: [] @@ -67,13 +67,16 @@ options: type: str required: true +seealso: + - module: community.general.keycloak_realm + description: Keycloak module which can be used specify list of supported locales using O(plugin.community.keycloak.general.keycloak_realm#module:supported_locales). + extends_documentation_fragment: - community.general.keycloak - community.general.keycloak.actiongroup_keycloak - community.general.attributes -author: - - Jakub Danek (@danekja) +author: Jakub Danek (@danekja) """ EXAMPLES = r""" @@ -137,14 +140,10 @@ EXAMPLES = r""" """ RETURN = r""" -msg: - description: Human-readable message about what action was taken. - returned: always - type: str end_state: description: - Final state of localization overrides for the locale after module execution. - - Contains the C(locale) and the list of C(overrides) as key/value items. + - Contains the O(locale) and the list of O(overrides) as key/value items. returned: on success type: dict contains: @@ -161,29 +160,6 @@ end_state: value: Hello - key: farewell value: Bye -diff: - description: - - When run with C(--diff), shows the before and after structures - for the locale and its overrides. - returned: when supported and requested - type: dict - contains: - before: - description: State of localization overrides before execution - type: dict - sample: - - key: greeting - value: Hello - - key: farewell - value: Bye - after: - description: State of localization overrides after execution - type: dict - sample: - - key: greeting - value: Hello - - key: farewell - value: Bye """ from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ From 48c0b95a4711c57779c2c002890342bd3009b6b2 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:29:35 +0200 Subject: [PATCH 18/33] rev: fix intermodule doc reference --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index b4e01c95e2..7f72269c00 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -69,7 +69,7 @@ options: seealso: - module: community.general.keycloak_realm - description: Keycloak module which can be used specify list of supported locales using O(plugin.community.keycloak.general.keycloak_realm#module:supported_locales). + description: Keycloak module which can be used specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales). extends_documentation_fragment: - community.general.keycloak From 9f7b2ce5052d45c49bfbcbc6083066f4117b016b Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:33:55 +0200 Subject: [PATCH 19/33] rev: fix copyright --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 7f72269c00..b315f4f558 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -2,7 +2,7 @@ # !/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: Contributors to the Ansible project +# Copyright: Jakub Danek # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later From a82d6713e6f94041cc9712486189f4e0b2a52e30 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:34:32 +0200 Subject: [PATCH 20/33] rev: list reference in docs --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index b315f4f558..d6a9e08c1c 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -50,7 +50,7 @@ options: overrides: description: - List of overrides to ensure for the locale when O(state=present). Each item is a mapping with - the record's O(overrides.key) and its O(overrides.value). + the record's O(overrides[].key) and its O(overrides[].value). - Ignored when O(state=absent). type: list elements: dict From 13873540fd0c488f69ef665686f8842c22e558d5 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:35:35 +0200 Subject: [PATCH 21/33] rev: line too long in docs --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index d6a9e08c1c..7e700f3a33 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -69,7 +69,7 @@ options: seealso: - module: community.general.keycloak_realm - description: Keycloak module which can be used specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales). + description: You can specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales). extends_documentation_fragment: - community.general.keycloak From baf314f0aba67e807548bc70d3d6f18f7d166480 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:36:30 +0200 Subject: [PATCH 22/33] rev: remove year from copyright per guidelines --- tests/unit/plugins/modules/test_keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index b0fca33d1e..e93f7c6e3c 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -1,7 +1,7 @@ # Python # -*- coding: utf-8 -*- -# Copyright (c) 2025, Jakub Danek +# Copyright Jakub Danek # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later From aebfbf7d9fc69ba52d3450764890d0d0bd0fea7e Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 26 Sep 2025 09:39:34 +0200 Subject: [PATCH 23/33] rev: maybe this is valid copyright line? --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 7e700f3a33..2eb73fb593 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -2,7 +2,7 @@ # !/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: Jakub Danek +# Copyright Jakub Danek # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later From 66710e452c00ae014000d1c3025be9f72fcd3154 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 3 Oct 2025 13:23:39 +0200 Subject: [PATCH 24/33] Update plugins/modules/keycloak_realm_localization.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 2eb73fb593..02bcc8cea4 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -328,7 +328,7 @@ def main(): if module.check_mode: - if len(old_overrides) > 0: + if old_overrides: result['changed'] = True result['msg'] = "All overrides for locale %s would be deleted." % (locale) else: From d3ca30d53ee40d6df34651444c68993147d920e7 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Fri, 3 Oct 2025 13:25:30 +0200 Subject: [PATCH 25/33] rev: cleaner sorting in _normalize_overrides_from_api --- plugins/modules/keycloak_realm_localization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 02bcc8cea4..db3beac55e 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -180,10 +180,10 @@ def _normalize_overrides_from_api(current): return [] # Convert mapping to list of key/value dicts - items = [{'key': k, 'value': v} for k, v in current.items()] + items = [{'key': k, 'value': v} for k, v in sorted(current.items())] # Sort for stable comparisons and diff output - return sorted(items, key=lambda x: x['key']) + return items def main(): From 1661120e4147d5023085901c752c7d32e63d5ceb Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 15:16:01 +0100 Subject: [PATCH 26/33] reformat according to new project guidelines --- .../identity/keycloak/keycloak.py | 92 ++++----- .../modules/keycloak_realm_localization.py | 116 +++++------ .../test_keycloak_realm_localization.py | 180 ++++++++++-------- 3 files changed, 202 insertions(+), 186 deletions(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index f35064600c..194893dd52 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -389,7 +389,7 @@ class KeycloakAPI: self.restheaders = connection_header self.http_agent = self.module.params.get("http_agent") - def _request(self, url: str, method: str, data: str | bytes | None = None, headers = None): + def _request(self, url: str, method: str, data: str | bytes | None = None, headers=None): """Makes a request to Keycloak and returns the raw response. If a 401 is returned, attempts to re-authenticate using first the module's refresh_token (if provided) @@ -597,77 +597,79 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc()) -def get_localization_values(self, locale, realm="master"): - """ - Get all localization overrides for a given realm and locale. + def get_localization_values(self, locale: str, realm: str = "master") -> dict[str, str]: + """ + Get all localization overrides for a given realm and locale. - Parameters: - locale (str): Locale code (for example, 'en', 'fi', 'de'). - realm (str): Realm name. Defaults to 'master'. + :param locale: Locale code (for example, 'en', 'fi', 'de'). + :param realm: Realm name. Defaults to 'master'. - Returns: - dict[str, str]: Mapping of localization keys to override values. + :return: Mapping of localization keys to override values. - Raises: - KeycloakError: Wrapped HTTP/JSON error with context + :raise KeycloakError: Wrapped HTTP/JSON error with context """ realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) try: - return self._request_and_deserialize(realm_url, method='GET') + return self._request_and_deserialize(realm_url, method="GET") except Exception as e: - self.fail_request(e, msg='Could not read localization overrides for realm %s, locale %s: %s' % (realm, locale, str(e)), - exception=traceback.format_exc()) + self.fail_request( + e, + msg="Could not read localization overrides for realm %s, locale %s: %s" % (realm, locale, str(e)), + exception=traceback.format_exc(), + ) -def set_localization_value(self, locale, key, value, realm="master"): - """ - Create or update a single localization override for the given key. + def set_localization_value(self, locale: str, key: str, value: str, realm: str = "master"): + """ + Create or update a single localization override for the given key. - Parameters: - locale (str): Locale code (for example, 'en'). - key (str): Localization message key to set. - value (str): Override value to set. - realm (str): Realm name. Defaults to 'master'. + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to set. + :param value: Override value to set. + :param realm: Realm name. Defaults to 'master'. - Returns: - HTTPResponse: Response object on success. + :return: HTTPResponse: Response object on success. - Raises: - KeycloakError: Wrapped HTTP error with context + :raise KeycloakError: Wrapped HTTP error with context """ realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) - headers = self.restheaders.copy() - headers['Content-Type'] = 'text/plain; charset=utf-8' + headers = self.restheaders.copy() + headers["Content-Type"] = "text/plain; charset=utf-8" try: - return self._request(realm_url, method='PUT', data=to_native(value), headers=headers) + return self._request(realm_url, method="PUT", data=to_native(value), headers=headers) except Exception as e: - self.fail_request(e, msg='Could not set localization value in realm %s, locale %s: %s=%s: %s' % (realm, locale, key, value, str(e)), - exception=traceback.format_exc()) + self.fail_request( + e, + msg="Could not set localization value in realm %s, locale %s: %s=%s: %s" + % (realm, locale, key, value, str(e)), + exception=traceback.format_exc(), + ) -def delete_localization_value(self, locale, key, realm="master"): - """ - Delete a single localization override key for the given locale. + def delete_localization_value(self, locale: str, key: str, realm: str = "master"): + """ + Delete a single localization override key for the given locale. - Parameters: - locale (str): Locale code (for example, 'en'). - key (str): Localization message key to delete. - realm (str): Realm name. Defaults to 'master'. + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to delete. + :param realm: Realm name. Defaults to 'master'. - Returns: - HTTPResponse: Response object on success. + :return: HTTPResponse: Response object on success. - Raises: - KeycloakError: Wrapped HTTP error with context + :raise KeycloakError: Wrapped HTTP error with context """ realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) try: - return self._request(realm_url, method='DELETE') + return self._request(realm_url, method="DELETE") except Exception as e: - self.fail_request(e, msg='Could not delete localization value in realm %s, locale %s, key %s: %s' % (realm, locale, key, str(e)), - exception=traceback.format_exc()) + self.fail_request( + e, + msg="Could not delete localization value in realm %s, locale %s, key %s: %s" + % (realm, locale, key, str(e)), + exception=traceback.format_exc(), + ) def get_clients(self, realm: str = "master", filter=None): """Obtains client representations for clients in a realm diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index db3beac55e..14aec450c7 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -7,6 +7,7 @@ # https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type DOCUMENTATION = r""" @@ -162,8 +163,12 @@ end_state: value: Bye """ -from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ - keycloak_argument_spec, get_token, KeycloakError +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + keycloak_argument_spec, + get_token, + KeycloakError, +) from ansible.module_utils.basic import AnsibleModule from copy import deepcopy @@ -180,7 +185,7 @@ def _normalize_overrides_from_api(current): return [] # Convert mapping to list of key/value dicts - items = [{'key': k, 'value': v} for k, v in sorted(current.items())] + items = [{"key": k, "value": v} for k, v in sorted(current.items())] # Sort for stable comparisons and diff output return items @@ -197,28 +202,30 @@ def main(): # Describe a single override record overrides_spec = dict( - key=dict(type='str', no_log=False, required=True), - value=dict(type='str', required=True), + key=dict(type="str", no_log=False, required=True), + value=dict(type="str", required=True), ) # Module-specific arguments meta_args = dict( - locale=dict(type='str', required=True), - 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=[]), + locale=dict(type="str", required=True), + 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=[]), ) argument_spec.update(meta_args) - module = AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True, - # Require token OR full credential set. This mirrors other Keycloak modules. - required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), - required_together=([['auth_realm', 'auth_username', 'auth_password']])) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + # Require token OR full credential set. This mirrors other Keycloak modules. + required_one_of=([["token", "auth_realm", "auth_username", "auth_password"]]), + required_together=([["auth_realm", "auth_username", "auth_password"]]), + ) # Initialize the result object used by Ansible - result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) # Obtain access token, initialize API try: @@ -229,33 +236,32 @@ def main(): kc = KeycloakAPI(module, connection_header) # Convenience locals for frequently used parameters - locale = module.params.get('locale') - state = module.params.get('state') - parent_id = module.params.get('parent_id') + locale = module.params.get("locale") + state = module.params.get("state") + parent_id = module.params.get("parent_id") - desired_raw = module.params.get('overrides') or [] - desired_map = {r['key']: r.get('value') for r in desired_raw} - desired_overrides = [{'key': k, 'value': v} for k, v in sorted(desired_map.items())] + desired_raw = module.params.get("overrides") or [] + desired_map = {r["key"]: r.get("value") for r in desired_raw} + desired_overrides = [{"key": k, "value": v} for k, v in sorted(desired_map.items())] # Fetch current overrides and normalize to comparable structure old_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) before = { - 'locale': locale, - 'overrides': deepcopy(old_overrides), + "locale": locale, + "overrides": deepcopy(old_overrides), } # Proposed state used for diff reporting changeset = { - 'locale': locale, - 'overrides': [], + "locale": locale, + "overrides": [], } # Default to no change; flip to True when updates/deletes are needed - result['changed'] = False + result["changed"] = False - if state == 'present': - - changeset['overrides'] = deepcopy(desired_overrides) + if state == "present": + changeset["overrides"] = deepcopy(desired_overrides) # Compute two sets: # - to_update: keys missing or with different values @@ -268,13 +274,12 @@ def main(): override_found = False for override in to_remove: - - if override['key'] == record['key']: + if override["key"] == record["key"]: override_found = True # Value differs -> update needed - if override['value'] != record['value']: - result['changed'] = True + if override["value"] != record["value"]: + result["changed"] = True to_update.append(record) # Remove processed item so what's left in to_remove are deletions @@ -284,68 +289,65 @@ def main(): if not override_found: # New key, must be created to_update.append(record) - result['changed'] = True + result["changed"] = True # Any leftovers in to_remove must be deleted if to_remove: - result['changed'] = True + result["changed"] = True - if result['changed']: + if result["changed"]: if module._diff: - result['diff'] = dict(before=before, after=changeset) + result["diff"] = dict(before=before, after=changeset) if module.check_mode: # Dry-run: report intent without side effects - result['msg'] = "Locale %s overrides would be updated." % (locale) + result["msg"] = "Locale %s overrides would be updated." % (locale) else: - for override in to_remove: - kc.delete_localization_value(locale, override['key'], parent_id) + kc.delete_localization_value(locale, override["key"], parent_id) for override in to_update: - kc.set_localization_value(locale, override['key'], override['value'], parent_id) + kc.set_localization_value(locale, override["key"], override["value"], parent_id) - result['msg'] = "Locale %s overrides have been updated." % (locale) + result["msg"] = "Locale %s overrides have been updated." % (locale) else: - result['msg'] = "Locale %s overrides are in sync." % (locale) + result["msg"] = "Locale %s overrides are in sync." % (locale) # For accurate end_state, read back from API unless we are in check_mode if not module.check_mode: final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) else: - final_overrides = ['overrides'] + final_overrides = ["overrides"] - result['end_state'] = {'locale': locale, 'overrides': final_overrides} + result["end_state"] = {"locale": locale, "overrides": final_overrides} - elif state == 'absent': + elif state == "absent": # Full removal of locale overrides if module._diff: - result['diff'] = dict(before=before, after=changeset) + 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) + result["changed"] = True + result["msg"] = "All overrides for locale %s would be deleted." % (locale) else: - result['msg'] = "No overrides for locale %s to be deleted." % (locale) + result["msg"] = "No overrides for locale %s to be deleted." % (locale) else: - for override in old_overrides: - kc.delete_localization_value(locale, override['key'], parent_id) - result['changed'] = True + kc.delete_localization_value(locale, override["key"], parent_id) + result["changed"] = True - result['msg'] = "Locale %s has no overrides." % (locale) + result["msg"] = "Locale %s has no overrides." % (locale) - result['end_state'] = changeset + result["end_state"] = changeset module.exit_json(**result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py index e93f7c6e3c..fcd3811e14 100644 --- a/tests/unit/plugins/modules/test_keycloak_realm_localization.py +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -7,9 +7,12 @@ # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function + __metaclass__ = type from contextlib import contextmanager +from io import StringIO +from itertools import count from ansible_collections.community.internal_test_tools.tests.unit.compat import unittest from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch @@ -22,10 +25,6 @@ from ansible_collections.community.internal_test_tools.tests.unit.plugins.module from ansible_collections.community.general.plugins.modules import keycloak_realm_localization -from itertools import count - -from io import StringIO - @contextmanager def patch_keycloak_api(get_localization_values=None, set_localization_value=None, delete_localization_value=None): @@ -33,9 +32,11 @@ def patch_keycloak_api(get_localization_values=None, set_localization_value=None Patch KeycloakAPI methods used by the module under test. """ obj = keycloak_realm_localization.KeycloakAPI - with patch.object(obj, 'get_localization_values', side_effect=get_localization_values) as mock_get_values: - with patch.object(obj, 'set_localization_value', side_effect=set_localization_value) as mock_set_value: - with patch.object(obj, 'delete_localization_value', side_effect=delete_localization_value) as mock_del_value: + with patch.object(obj, "get_localization_values", side_effect=get_localization_values) as mock_get_values: + with patch.object(obj, "set_localization_value", side_effect=set_localization_value) as mock_set_value: + with patch.object( + obj, "delete_localization_value", side_effect=delete_localization_value + ) as mock_del_value: yield mock_get_values, mock_set_value, mock_del_value @@ -43,21 +44,20 @@ def get_response(object_with_future_response, method, get_id_call_count): if callable(object_with_future_response): return object_with_future_response() if isinstance(object_with_future_response, dict): - return get_response( - object_with_future_response[method], method, get_id_call_count) + return get_response(object_with_future_response[method], method, get_id_call_count) if isinstance(object_with_future_response, list): call_number = next(get_id_call_count) - return get_response( - object_with_future_response[call_number], method, get_id_call_count) + return get_response(object_with_future_response[call_number], method, get_id_call_count) return object_with_future_response def build_mocked_request(get_id_user_count, response_dict): def _mocked_requests(*args, **kwargs): url = args[0] - method = kwargs['method'] + method = kwargs["method"] future_response = response_dict.get(url, None) return get_response(future_response, method, get_id_user_count) + return _mocked_requests @@ -65,19 +65,23 @@ def create_wrapper(text_as_string): """Allow to mock many times a call to one address. Without this function, the StringIO is empty for the second call. """ + def _create_wrapper(): return StringIO(text_as_string) + return _create_wrapper def mock_good_connection(): token_response = { - 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), + "http://keycloak.url/auth/realms/master/protocol/openid-connect/token": create_wrapper( + '{"access_token": "alongtoken"}' + ), } return patch( - 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + "ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url", side_effect=build_mocked_request(count(), token_response), - autospec=True + autospec=True, ) @@ -89,51 +93,54 @@ class TestKeycloakRealmLocalization(ModuleTestCase): def test_present_no_change_in_sync(self): """Desired overrides already match, no change.""" module_args = { - 'auth_keycloak_url': 'http://keycloak.url/auth', - 'token': '{{ access_token }}', - 'parent_id': 'my-realm', - 'locale': 'en', - 'state': 'present', - 'overrides': [ - {'key': 'greeting', 'value': 'Hello'}, - {'key': 'farewell', 'value': 'Bye'}, + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "greeting", "value": "Hello"}, + {"key": "farewell", "value": "Bye"}, ], } # get_localization_values is called twice: before and after return_value_get_localization_values = [ - {'greeting': 'Hello', 'farewell': 'Bye'}, - {'greeting': 'Hello', 'farewell': 'Bye'}, + {"greeting": "Hello", "farewell": "Bye"}, + {"greeting": "Hello", "farewell": "Bye"}, ] with set_module_args(module_args): with mock_good_connection(): - with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ - as (mock_get_values, mock_set_value, mock_del_value): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) 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) self.assertEqual(mock_set_value.call_count, 0) self.assertEqual(mock_del_value.call_count, 0) - self.assertIs(exec_info.exception.args[0]['changed'], False) + 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 + "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'}, + {"a": "1", "b": "2"}, + {"a": "1-new", "c": "3"}, ] return_value_set = [None, None] return_value_delete = [None] @@ -141,9 +148,9 @@ class TestKeycloakRealmLocalization(ModuleTestCase): 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, + 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() @@ -153,30 +160,33 @@ class TestKeycloakRealmLocalization(ModuleTestCase): 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) + 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 = { - 'auth_keycloak_url': 'http://keycloak.url/auth', - 'token': '{{ access_token }}', - 'parent_id': 'my-realm', - 'locale': 'en', - 'state': 'present', - 'overrides': [ - {'key': 'x', 'value': '1'}, # change - {'key': 'y', 'value': '2'}, # create + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "x", "value": "1"}, # change + {"key": "y", "value": "2"}, # create ], - '_ansible_check_mode': True, # signal for readers; set_module_args is what matters + "_ansible_check_mode": True, # signal for readers; set_module_args is what matters } return_value_get_localization_values = [ - {'x': '0'}, + {"x": "0"}, ] with set_module_args(module_args): with mock_good_connection(): - with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ - as (mock_get_values, mock_set_value, mock_del_value): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) as ( + mock_get_values, + mock_set_value, + mock_del_value, + ): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -184,28 +194,28 @@ class TestKeycloakRealmLocalization(ModuleTestCase): self.assertEqual(mock_get_values.call_count, 1) self.assertEqual(mock_set_value.call_count, 0) self.assertEqual(mock_del_value.call_count, 0) - self.assertIs(exec_info.exception.args[0]['changed'], True) - self.assertIn('would be updated', exec_info.exception.args[0]['msg']) + 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', + "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'}, + {"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, + 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() @@ -213,16 +223,16 @@ class TestKeycloakRealmLocalization(ModuleTestCase): 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) + 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 = { - 'auth_keycloak_url': 'http://keycloak.url/auth', - 'token': '{{ access_token }}', - 'parent_id': 'my-realm', - 'locale': 'en', - 'state': 'absent', + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "absent", } return_value_get_localization_values = [ {}, @@ -230,38 +240,40 @@ class TestKeycloakRealmLocalization(ModuleTestCase): with set_module_args(module_args): with mock_good_connection(): - with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ - as (mock_get_values, mock_set_value, mock_del_value): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) 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, 0) self.assertEqual(mock_set_value.call_count, 0) - self.assertIs(exec_info.exception.args[0]['changed'], False) + self.assertIs(exec_info.exception.args[0]["changed"], False) def test_present_missing_value_validation(self): """Validation error when state=present and value is missing.""" module_args = { - 'auth_keycloak_url': 'http://keycloak.url/auth', - 'token': '{{ access_token }}', - 'parent_id': 'my-realm', - 'locale': 'en', - 'state': 'present', - 'overrides': [ - {'key': 'greeting'}, + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "parent_id": "my-realm", + "locale": "en", + "state": "present", + "overrides": [ + {"key": "greeting"}, ], } with set_module_args(module_args): with mock_good_connection(): - with patch_keycloak_api() \ - as (_mock_get_values, _mock_set_value, _mock_del_value): + with patch_keycloak_api() as (_mock_get_values, _mock_set_value, _mock_del_value): with self.assertRaises(AnsibleFailJson) as exec_info: self.module.main() - self.assertIn("missing required arguments: value", exec_info.exception.args[0]['msg']) + self.assertIn("missing required arguments: value", exec_info.exception.args[0]["msg"]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() From 460ea43f8e74981d942551b47e7760a421ee722f Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 16:12:03 +0100 Subject: [PATCH 27/33] feat: add support for append parameter in keycloak_realm_localization module --- .../modules/keycloak_realm_localization.py | 68 ++++-- .../test_keycloak_realm_localization.py | 207 ++++++++++++------ 2 files changed, 196 insertions(+), 79 deletions(-) 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() From 3fe0d5ede90fa038b6fabc5ae00e1b8b4ddc7301 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:01:56 +0100 Subject: [PATCH 28/33] rev: remove whitespace from description and --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 3aa8bb8f13..1e825e1435 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -33,7 +33,7 @@ options: 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 + - 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 From 0839dd2dd5064a52dcc2fc339b0bc64045139213 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:24:11 +0100 Subject: [PATCH 29/33] feat: set default value for value field in overrides_spec --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 1e825e1435..5669b9f874 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -217,7 +217,7 @@ def main(): # Describe a single override record overrides_spec = dict( key=dict(type="str", no_log=False, required=True), - value=dict(type="str", required=True), + value=dict(type="str", required=True, default=""), ) # Module-specific arguments From e364f97963c8dad08700d589d901be0aad9796d1 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:25:31 +0100 Subject: [PATCH 30/33] doc: align existing examples with newly added append attribute --- plugins/modules/keycloak_realm_localization.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 5669b9f874..2ef48960bb 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -103,6 +103,7 @@ EXAMPLES = r""" parent_id: my-realm locale: en state: present + append: false overrides: - key: greeting value: "Hello" @@ -118,6 +119,7 @@ EXAMPLES = r""" parent_id: my-realm locale: fi state: present + append: false overrides: - key: app.title value: "Sovellukseni" @@ -130,9 +132,10 @@ EXAMPLES = r""" auth_realm: master auth_username: USERNAME auth_password: PASSWORD - parent_id: my-realm + parent_id: my-realm locale: de state: absent + append: false delegate_to: localhost - name: Dry run - see what would change for locale "en" From 093192458e891e341928c2c7f9bbf373bd2584fe Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:29:19 +0100 Subject: [PATCH 31/33] bah --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 2ef48960bb..81e949815e 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -220,7 +220,7 @@ def main(): # Describe a single override record overrides_spec = dict( key=dict(type="str", no_log=False, required=True), - value=dict(type="str", required=True, default=""), + value=dict(type="str", default=""), ) # Module-specific arguments From dad1900221fc53ee6c4b30192e9aa2ad4a7d9756 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:30:39 +0100 Subject: [PATCH 32/33] doc: update documentation after overrides.value is no longer required. --- plugins/modules/keycloak_realm_localization.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 81e949815e..3c76f26803 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -76,9 +76,10 @@ options: required: true value: description: - - The override value for the message key. + - The override value for the message key. If ommitted, value will be set to an empty string. type: str - required: true + default: "" + required: false seealso: - module: community.general.keycloak_realm From e111fa4b23f4d6b121ad04379b01f4f33aeab5e2 Mon Sep 17 00:00:00 2001 From: Jakub Danek Date: Tue, 3 Feb 2026 17:30:56 +0100 Subject: [PATCH 33/33] doc: update documentation after overrides.value is no longer required. - typo --- plugins/modules/keycloak_realm_localization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index 3c76f26803..09be06d2d5 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -76,7 +76,7 @@ options: required: true value: description: - - The override value for the message key. If ommitted, value will be set to an empty string. + - The override value for the message key. If omitted, value will be set to an empty string. type: str default: "" required: false