mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-03-22 05:09:12 +00:00
[PR #10841/986118c0 backport][stable-12] keycloak_realm_localization: new module - realm localization control (#11517)
keycloak_realm_localization: new module - realm localization control (#10841)
* add support for management of keycloak localizations
* unit test for keycloak localization support
* keycloak_realm_localization botmeta record
* rev: improvements after code review
(cherry picked from commit 986118c0af)
Co-authored-by: Jakub Danek <danekja@users.noreply.github.com>
This commit is contained in:
parent
42c20a754b
commit
5dcb3b8f59
5 changed files with 845 additions and 7 deletions
2
.github/BOTMETA.yml
vendored
2
.github/BOTMETA.yml
vendored
|
|
@ -857,6 +857,8 @@ files:
|
||||||
maintainers: fynncfchen
|
maintainers: fynncfchen
|
||||||
$modules/keycloak_realm_key.py:
|
$modules/keycloak_realm_key.py:
|
||||||
maintainers: mattock
|
maintainers: mattock
|
||||||
|
$modules/keycloak_realm_localization.py:
|
||||||
|
maintainers: danekja
|
||||||
$modules/keycloak_role.py:
|
$modules/keycloak_role.py:
|
||||||
maintainers: laurpaum
|
maintainers: laurpaum
|
||||||
$modules/keycloak_user.py:
|
$modules/keycloak_user.py:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ action_groups:
|
||||||
- keycloak_realm
|
- keycloak_realm
|
||||||
- keycloak_realm_key
|
- keycloak_realm_key
|
||||||
- keycloak_realm_keys_metadata_info
|
- keycloak_realm_keys_metadata_info
|
||||||
|
- keycloak_realm_localization
|
||||||
- keycloak_realm_rolemapping
|
- keycloak_realm_rolemapping
|
||||||
- keycloak_role
|
- keycloak_role
|
||||||
- keycloak_user
|
- keycloak_user
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ URL_REALMS = "{url}/admin/realms"
|
||||||
URL_REALM = "{url}/admin/realms/{realm}"
|
URL_REALM = "{url}/admin/realms/{realm}"
|
||||||
URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys"
|
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_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token"
|
||||||
URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}"
|
URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}"
|
||||||
URL_CLIENTS = "{url}/admin/realms/{realm}/clients"
|
URL_CLIENTS = "{url}/admin/realms/{realm}/clients"
|
||||||
|
|
@ -386,7 +389,9 @@ class KeycloakAPI:
|
||||||
self.restheaders = connection_header
|
self.restheaders = connection_header
|
||||||
self.http_agent = self.module.params.get("http_agent")
|
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, *, extra_headers: dict[str, str] | None = None
|
||||||
|
):
|
||||||
"""Makes a request to Keycloak and returns the raw response.
|
"""Makes a request to Keycloak and returns the raw response.
|
||||||
If a 401 is returned, attempts to re-authenticate
|
If a 401 is returned, attempts to re-authenticate
|
||||||
using first the module's refresh_token (if provided)
|
using first the module's refresh_token (if provided)
|
||||||
|
|
@ -397,17 +402,18 @@ class KeycloakAPI:
|
||||||
:param url: request path
|
:param url: request path
|
||||||
:param method: request method (e.g., 'GET', 'POST', etc.)
|
:param method: request method (e.g., 'GET', 'POST', etc.)
|
||||||
:param data: (optional) data for request
|
:param data: (optional) data for request
|
||||||
|
:param extra_headers headers to be sent with request, defaults to self.restheaders
|
||||||
:return: raw API response
|
:return: raw API response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def make_request_catching_401() -> object | HTTPError:
|
def make_request_catching_401(headers: dict[str, str]) -> object | HTTPError:
|
||||||
try:
|
try:
|
||||||
return open_url(
|
return open_url(
|
||||||
url,
|
url,
|
||||||
method=method,
|
method=method,
|
||||||
data=data,
|
data=data,
|
||||||
http_agent=self.http_agent,
|
http_agent=self.http_agent,
|
||||||
headers=self.restheaders,
|
headers=headers,
|
||||||
timeout=self.connection_timeout,
|
timeout=self.connection_timeout,
|
||||||
validate_certs=self.validate_certs,
|
validate_certs=self.validate_certs,
|
||||||
)
|
)
|
||||||
|
|
@ -416,7 +422,12 @@ class KeycloakAPI:
|
||||||
raise e
|
raise e
|
||||||
return e
|
return e
|
||||||
|
|
||||||
r = make_request_catching_401()
|
headers = self.restheaders
|
||||||
|
if extra_headers is not None:
|
||||||
|
headers = headers.copy()
|
||||||
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
r = make_request_catching_401(headers)
|
||||||
|
|
||||||
if isinstance(r, Exception):
|
if isinstance(r, Exception):
|
||||||
# Try to refresh token and retry, if available
|
# Try to refresh token and retry, if available
|
||||||
|
|
@ -426,7 +437,7 @@ class KeycloakAPI:
|
||||||
token = _request_token_using_refresh_token(self.module.params)
|
token = _request_token_using_refresh_token(self.module.params)
|
||||||
self.restheaders["Authorization"] = f"Bearer {token}"
|
self.restheaders["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
r = make_request_catching_401()
|
r = make_request_catching_401(headers)
|
||||||
except KeycloakError as e:
|
except KeycloakError as e:
|
||||||
# Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400
|
# Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400
|
||||||
if e.authError is not None and e.authError.code != 400: # type: ignore # TODO!
|
if e.authError is not None and e.authError.code != 400: # type: ignore # TODO!
|
||||||
|
|
@ -440,7 +451,7 @@ class KeycloakAPI:
|
||||||
token = _request_token_using_credentials(self.module.params)
|
token = _request_token_using_credentials(self.module.params)
|
||||||
self.restheaders["Authorization"] = f"Bearer {token}"
|
self.restheaders["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
r = make_request_catching_401()
|
r = make_request_catching_401(headers)
|
||||||
|
|
||||||
if isinstance(r, Exception):
|
if isinstance(r, Exception):
|
||||||
# Try to re-auth with client_id and client_secret, if available
|
# Try to re-auth with client_id and client_secret, if available
|
||||||
|
|
@ -451,7 +462,7 @@ class KeycloakAPI:
|
||||||
token = _request_token_using_client_credentials(self.module.params)
|
token = _request_token_using_client_credentials(self.module.params)
|
||||||
self.restheaders["Authorization"] = f"Bearer {token}"
|
self.restheaders["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
r = make_request_catching_401()
|
r = make_request_catching_401(headers)
|
||||||
except KeycloakError as e:
|
except KeycloakError as e:
|
||||||
# Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400
|
# Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400
|
||||||
if e.authError is not None and e.authError.code != 400: # type: ignore # TODO!
|
if e.authError is not None and e.authError.code != 400: # type: ignore # TODO!
|
||||||
|
|
@ -590,6 +601,78 @@ class KeycloakAPI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc())
|
self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc())
|
||||||
|
|
||||||
|
def get_localization_values(self, locale: str, realm: str = "master") -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get all localization overrides for a given realm and locale.
|
||||||
|
|
||||||
|
:param locale: Locale code (for example, 'en', 'fi', 'de').
|
||||||
|
:param realm: Realm name. Defaults to 'master'.
|
||||||
|
|
||||||
|
:return: Mapping of localization keys to override values.
|
||||||
|
|
||||||
|
: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")
|
||||||
|
except Exception as e:
|
||||||
|
self.fail_request(
|
||||||
|
e,
|
||||||
|
msg=f"Could not read localization overrides for realm {realm}, locale {locale}: {e}",
|
||||||
|
exception=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
: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'.
|
||||||
|
|
||||||
|
:return: HTTPResponse: Response object on success.
|
||||||
|
|
||||||
|
:raise KeycloakError: Wrapped HTTP error with context
|
||||||
|
"""
|
||||||
|
realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key)
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
headers["Content-Type"] = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._request(realm_url, method="PUT", data=to_native(value), extra_headers=headers)
|
||||||
|
except Exception as e:
|
||||||
|
self.fail_request(
|
||||||
|
e,
|
||||||
|
msg=f"Could not set localization value in realm {realm}, locale {locale}: {key}={value}: {e}",
|
||||||
|
exception=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_localization_value(self, locale: str, key: str, realm: str = "master"):
|
||||||
|
"""
|
||||||
|
Delete a single localization override key for the given locale.
|
||||||
|
|
||||||
|
:param locale: Locale code (for example, 'en').
|
||||||
|
:param key: Localization message key to delete.
|
||||||
|
:param realm: Realm name. Defaults to 'master'.
|
||||||
|
|
||||||
|
:return: HTTPResponse: Response object on success.
|
||||||
|
|
||||||
|
: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")
|
||||||
|
except Exception as e:
|
||||||
|
self.fail_request(
|
||||||
|
e,
|
||||||
|
msg=f"Could not delete localization value in realm {realm}, locale {locale}, key {key}: {e}",
|
||||||
|
exception=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
|
||||||
def get_clients(self, realm: str = "master", filter=None):
|
def get_clients(self, realm: str = "master", filter=None):
|
||||||
"""Obtains client representations for clients in a realm
|
"""Obtains client representations for clients in a realm
|
||||||
|
|
||||||
|
|
|
||||||
398
plugins/modules/keycloak_realm_localization.py
Normal file
398
plugins/modules/keycloak_realm_localization.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
# !/usr/bin/python
|
||||||
|
# Copyright Jakub Danek <danek.ja@gmail.com>
|
||||||
|
# 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 annotations
|
||||||
|
|
||||||
|
DOCUMENTATION = r"""
|
||||||
|
module: keycloak_realm_localization
|
||||||
|
|
||||||
|
short_description: Allows management of Keycloak realm localization overrides via the Keycloak API
|
||||||
|
|
||||||
|
version_added: 12.4.0
|
||||||
|
|
||||||
|
description:
|
||||||
|
- This module allows you to 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:
|
||||||
|
force:
|
||||||
|
description:
|
||||||
|
- If V(false), only the keys listed in the O(overrides) are modified by this module. Any other pre-existing
|
||||||
|
keys are ignored.
|
||||||
|
- If V(true), all locale overrides are made to match configuration of this module. For example any keys
|
||||||
|
missing from the O(overrides) are removed regardless of O(state) value.
|
||||||
|
type: bool
|
||||||
|
default: false
|
||||||
|
locale:
|
||||||
|
description:
|
||||||
|
- Locale code for which the overrides apply (for example, V(en), V(fi), V(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 V(present), the set of overrides for the locale are made to match O(overrides).
|
||||||
|
If O(force) is V(true) keys not listed in O(overrides) are removed,
|
||||||
|
and the listed keys are created or updated.
|
||||||
|
If O(force) is V(false) keys not listed in O(overrides) are ignored,
|
||||||
|
and the listed keys are created or updated.
|
||||||
|
- On V(absent), overrides for the locale is removed. If O(force) is V(true), all keys are removed.
|
||||||
|
If O(force) is V(false), only the keys listed in O(overrides) are removed.
|
||||||
|
type: str
|
||||||
|
choices: ['present', 'absent']
|
||||||
|
default: present
|
||||||
|
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).
|
||||||
|
- Ignored when O(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. If omitted, value defaults to an empty string.
|
||||||
|
type: str
|
||||||
|
default: ""
|
||||||
|
required: false
|
||||||
|
|
||||||
|
seealso:
|
||||||
|
- module: community.general.keycloak_realm
|
||||||
|
description: You can specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales).
|
||||||
|
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- community.general.keycloak
|
||||||
|
- community.general.keycloak.actiongroup_keycloak
|
||||||
|
- community.general.attributes
|
||||||
|
|
||||||
|
author: Jakub Danek (@danekja)
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXAMPLES = r"""
|
||||||
|
- 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
|
||||||
|
force: true
|
||||||
|
overrides:
|
||||||
|
- key: greeting
|
||||||
|
value: "Hello"
|
||||||
|
- key: farewell
|
||||||
|
value: "Bye"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Replace listed 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
|
||||||
|
force: false
|
||||||
|
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
|
||||||
|
force: true
|
||||||
|
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
|
||||||
|
force: true
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Remove only the listed 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
|
||||||
|
force: false
|
||||||
|
overrides:
|
||||||
|
- key: app.title
|
||||||
|
- key: foo
|
||||||
|
- key: bar
|
||||||
|
delegate_to: localhost
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETURN = r"""
|
||||||
|
end_state:
|
||||||
|
description:
|
||||||
|
- Final state of localization overrides for the locale after module execution.
|
||||||
|
- Contains the O(locale) and the list of O(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
|
||||||
|
"""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import (
|
||||||
|
KeycloakAPI,
|
||||||
|
KeycloakError,
|
||||||
|
get_token,
|
||||||
|
keycloak_argument_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_overrides(current: dict | None) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Accepts:
|
||||||
|
- dict: {'k1': 'v1', ...}
|
||||||
|
Return a sorted list of {'key', 'value'}.
|
||||||
|
|
||||||
|
This helper provides a consistent shape for downstream comparison/diff logic.
|
||||||
|
"""
|
||||||
|
if not current:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [{"key": k, "value": v} for k, v in sorted(current.items())]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
argument_spec = keycloak_argument_spec()
|
||||||
|
|
||||||
|
# Single override record structure
|
||||||
|
overrides_spec = dict(
|
||||||
|
key=dict(type="str", no_log=False, required=True),
|
||||||
|
value=dict(type="str", default=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
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=[]),
|
||||||
|
force=dict(type="bool", default=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
argument_spec.update(meta_args)
|
||||||
|
|
||||||
|
module = AnsibleModule(
|
||||||
|
argument_spec=argument_spec,
|
||||||
|
supports_check_mode=True,
|
||||||
|
required_one_of=([["token", "auth_realm", "auth_username", "auth_password"]]),
|
||||||
|
required_together=([["auth_realm", "auth_username", "auth_password"]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
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["locale"]
|
||||||
|
state = module.params["state"]
|
||||||
|
parent_id = module.params["parent_id"]
|
||||||
|
force = module.params["force"]
|
||||||
|
|
||||||
|
desired_raw = module.params["overrides"]
|
||||||
|
desired_overrides = _normalize_overrides({r["key"]: r.get("value") for r in desired_raw})
|
||||||
|
|
||||||
|
old_overrides = _normalize_overrides(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": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = deepcopy(old_overrides)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ignore any left-overs in to_remove, force is false
|
||||||
|
if not force:
|
||||||
|
changeset["overrides"].extend(to_remove)
|
||||||
|
to_remove = []
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
result["changed"] = True
|
||||||
|
|
||||||
|
if result["changed"]:
|
||||||
|
if module._diff:
|
||||||
|
result["diff"] = dict(before=before, after=changeset)
|
||||||
|
|
||||||
|
if module.check_mode:
|
||||||
|
result["msg"] = f"Locale {locale} overrides would be updated."
|
||||||
|
|
||||||
|
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"] = f"Locale {locale} overrides have been updated."
|
||||||
|
|
||||||
|
else:
|
||||||
|
result["msg"] = f"Locale {locale} overrides are in sync."
|
||||||
|
|
||||||
|
# For accurate end_state, read back from API unless we are in check_mode
|
||||||
|
if not module.check_mode:
|
||||||
|
final_overrides = _normalize_overrides(kc.get_localization_values(locale, parent_id) or {})
|
||||||
|
|
||||||
|
else:
|
||||||
|
final_overrides = ["overrides"]
|
||||||
|
|
||||||
|
result["end_state"] = {"locale": locale, "overrides": final_overrides}
|
||||||
|
|
||||||
|
elif state == "absent":
|
||||||
|
if force:
|
||||||
|
to_remove = old_overrides
|
||||||
|
|
||||||
|
else:
|
||||||
|
# touch only overrides listed in parameters, leave the rest be
|
||||||
|
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
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
to_remove.remove(override)
|
||||||
|
|
||||||
|
changeset["overrides"] = to_keep
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
result["changed"] = True
|
||||||
|
|
||||||
|
if module._diff:
|
||||||
|
result["diff"] = dict(before=before, after=changeset)
|
||||||
|
|
||||||
|
if module.check_mode:
|
||||||
|
if result["changed"]:
|
||||||
|
result["msg"] = f"{len(to_remove)} overrides for locale {locale} would be deleted."
|
||||||
|
else:
|
||||||
|
result["msg"] = f"No overrides for locale {locale} to be deleted."
|
||||||
|
|
||||||
|
else:
|
||||||
|
for override in to_remove:
|
||||||
|
kc.delete_localization_value(locale, override["key"], parent_id)
|
||||||
|
|
||||||
|
if result["changed"]:
|
||||||
|
result["msg"] = f"{len(to_remove)} overrides for locale {locale} deleted."
|
||||||
|
else:
|
||||||
|
result["msg"] = f"No overrides for locale {locale} to be deleted."
|
||||||
|
|
||||||
|
result["end_state"] = changeset
|
||||||
|
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
354
tests/unit/plugins/modules/test_keycloak_realm_localization.py
Normal file
354
tests/unit/plugins/modules/test_keycloak_realm_localization.py
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
# Copyright Jakub Danek <danek.ja@gmail.com>
|
||||||
|
# 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 contextlib import contextmanager
|
||||||
|
from io import StringIO
|
||||||
|
from itertools import count
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import (
|
||||||
|
AnsibleExitJson,
|
||||||
|
ModuleTestCase,
|
||||||
|
set_module_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ansible_collections.community.general.plugins.modules import keycloak_realm_localization
|
||||||
|
|
||||||
|
|
||||||
|
@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().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_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_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_value_defaults_to_empty_string(self):
|
||||||
|
"""When value is omitted, it defaults to empty string."""
|
||||||
|
module_args = {
|
||||||
|
"auth_keycloak_url": "http://keycloak.url/auth",
|
||||||
|
"token": "{{ access_token }}",
|
||||||
|
"parent_id": "my-realm",
|
||||||
|
"locale": "en",
|
||||||
|
"state": "present",
|
||||||
|
"overrides": [
|
||||||
|
{"key": "greeting"}, # value omitted, should default to ""
|
||||||
|
],
|
||||||
|
}
|
||||||
|
# Before: greeting="Hello"; After: greeting="" (empty string)
|
||||||
|
return_value_get_localization_values = [
|
||||||
|
{"greeting": "Hello"},
|
||||||
|
{"greeting": ""},
|
||||||
|
]
|
||||||
|
return_value_set = [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)
|
||||||
|
self.assertEqual(mock_del_value.call_count, 0)
|
||||||
|
# One set call to update 'greeting' to empty string
|
||||||
|
self.assertEqual(mock_set_value.call_count, 1)
|
||||||
|
self.assertIs(exec_info.exception.args[0]["changed"], True)
|
||||||
|
|
||||||
|
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",
|
||||||
|
"force": False,
|
||||||
|
"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",
|
||||||
|
"force": True,
|
||||||
|
"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",
|
||||||
|
"force": False,
|
||||||
|
"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",
|
||||||
|
"force": True,
|
||||||
|
}
|
||||||
|
# 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue