#!/usr/bin/python from __future__ import (absolute_import, division, print_function) __metaclass__ = type DOCUMENTATION = r''' --- module: keycloak_kcadm_update short_description: Create or update Keycloak clients/components/client-scopes/realms via kcadm. description: - Generic "create or update" module for Keycloak objects using kcadm. - Resolves the object by a lookup field, reads current state if it exists, deep-merges the desired state on top (optionally only a sub-path), preserves immutable fields, applies forced attributes and then updates or creates the object. options: object_kind: description: Kind of the Keycloak object. type: str required: True choices: [client, component, client-scope, realm] lookup_value: description: Value to look up the object (e.g. clientId, component name, realm id). type: str required: True desired: description: Desired object dictionary. type: dict required: True lookup_field: description: - Lookup field name. - Defaults depend on object_kind: - client -> clientId - component -> name - client-scope -> name - realm -> id type: str required: False merge_path: description: - If set (e.g. C(config)), only this subkey is merged into the current object. - If omitted, the whole object is merged. type: str required: False force_attrs: description: - Attributes that are always applied last on the final payload. type: dict required: False kcadm_exec: description: - Command to execute kcadm. - E.g. C(docker exec -i keycloak /opt/keycloak/bin/kcadm.sh). type: str required: True realm: description: - Realm name used for non-realm objects. type: str required: False assert_mode: description: - If true, additional safety checks are applied (e.g. providerId match for components). type: bool required: False default: True author: - Your Name ''' EXAMPLES = r''' - name: Create or update a Keycloak client (merge full object) keycloak_kcadm_update: object_kind: client lookup_value: "{{ KEYCLOAK_CLIENT_ID }}" desired: "{{ KEYCLOAK_DICTIONARY_CLIENT }}" kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}" realm: "{{ KEYCLOAK_REALM }}" - name: Create or update LDAP component (merge only config) keycloak_kcadm_update: object_kind: component lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}" desired: "{{ KEYCLOAK_DICTIONARY_LDAP }}" merge_path: config kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}" realm: "{{ KEYCLOAK_REALM }}" force_attrs: parentId: "{{ KEYCLOAK_REALM }}" ''' RETURN = r''' changed: description: Whether the object was created or updated. type: bool returned: always object_exists: description: Whether the object was found by the lookup. type: bool returned: always object_id: description: Resolved object id (if exists). type: str returned: always result: description: The final payload that was sent to Keycloak. type: dict returned: always ''' from ansible.module_utils.basic import AnsibleModule import json import subprocess from copy import deepcopy def run_kcadm(module, cmd, ignore_rc=False): """Run a shell command for kcadm.""" try: rc = subprocess.run( cmd, shell=True, check=not ignore_rc, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout = rc.stdout.decode('utf-8').strip() stderr = rc.stderr.decode('utf-8').strip() return rc.returncode, stdout, stderr except Exception as e: module.fail_json(msg="Failed to run kcadm command", cmd=cmd, error=str(e)) def deep_merge(a, b): """Recursive dict merge similar to Ansible's combine(recursive=True).""" result = deepcopy(a) for k, v in b.items(): if ( k in result and isinstance(result[k], dict) and isinstance(v, dict) ): result[k] = deep_merge(result[k], v) else: result[k] = deepcopy(v) return result def get_api_and_lookup_field(object_kind, lookup_field): if object_kind == 'client': api = 'clients' default_lookup = 'clientId' elif object_kind == 'component': api = 'components' default_lookup = 'name' elif object_kind == 'client-scope': api = 'client-scopes' default_lookup = 'name' elif object_kind == 'realm': api = 'realms' default_lookup = 'id' else: api = '' default_lookup = '' return api, (lookup_field or default_lookup) def resolve_object_id(module, object_kind, api, lookup_field, lookup_value, realm, kcadm_exec): """Return (object_id, exists_flag).""" if lookup_field == 'id': obj_id = str(lookup_value).strip() if not obj_id: return '', False return obj_id, True if object_kind == 'realm': # For realms we treat lookup_value as id/realm name; we will verify on get. return str(lookup_value), True if object_kind == 'client-scope': cmd = f"{kcadm_exec} get client-scopes -r {realm} --format json" rc, out, err = run_kcadm(module, cmd, ignore_rc=True) if rc != 0 or not out: return '', False try: scopes = json.loads(out) except Exception: return '', False for obj in scopes: if obj.get(lookup_field) == lookup_value: return obj.get('id', ''), True return '', False # Generic path (client, component via query) cmd = ( f"{kcadm_exec} get {api} -r {realm} " f"--query '{lookup_field}={lookup_value}' " f"--fields id --format json" ) rc, out, err = run_kcadm(module, cmd, ignore_rc=True) if rc != 0 or not out: return '', False try: data = json.loads(out) if not data: return '', False return data[0].get('id', ''), True except Exception: return '', False def get_current_object(module, object_kind, api, object_id, realm, kcadm_exec): if object_kind == 'realm': cmd = f"{kcadm_exec} get {api}/{object_id} --format json" else: cmd = f"{kcadm_exec} get {api}/{object_id} -r {realm} --format json" rc, out, err = run_kcadm(module, cmd) try: return json.loads(out) except Exception as e: module.fail_json(msg="Failed to parse current Keycloak object JSON", error=str(e), stdout=out) def send_update(module, object_kind, api, object_id, realm, kcadm_exec, payload): payload_json = json.dumps(payload) if object_kind == 'realm': cmd = f"cat <<'JSON' | {kcadm_exec} update {api}/{object_id} -f -\n{payload_json}\nJSON" else: cmd = f"cat <<'JSON' | {kcadm_exec} update {api}/{object_id} -r {realm} -f -\n{payload_json}\nJSON" rc, out, err = run_kcadm(module, cmd, ignore_rc=True) return rc, out, err def send_create(module, object_kind, api, realm, kcadm_exec, payload): payload_json = json.dumps(payload) if object_kind == 'realm': cmd = f"cat <<'JSON' | {kcadm_exec} create {api} -f -\n{payload_json}\nJSON" else: cmd = f"cat <<'JSON' | {kcadm_exec} create {api} -r {realm} -f -\n{payload_json}\nJSON" rc, out, err = run_kcadm(module, cmd, ignore_rc=True) return rc, out, err def run_module(): module_args = dict( object_kind=dict(type='str', required=True), lookup_value=dict(type='str', required=True), desired=dict(type='dict', required=True), lookup_field=dict(type='str', required=False, default=None), merge_path=dict(type='str', required=False, default=None), force_attrs=dict(type='dict', required=False, default=None), kcadm_exec=dict(type='str', required=True), realm=dict(type='str', required=False, default=None), assert_mode=dict(type='bool', required=False, default=True), ) result = dict( changed=False, object_exists=False, object_id='', result={}, ) module = AnsibleModule( argument_spec=module_args, supports_check_mode=False, ) object_kind = module.params['object_kind'] lookup_value = module.params['lookup_value'] desired = module.params['desired'] or {} lookup_field = module.params['lookup_field'] merge_path = module.params['merge_path'] force_attrs = module.params['force_attrs'] or {} kcadm_exec = module.params['kcadm_exec'] realm = module.params['realm'] assert_mode = module.params['assert_mode'] if object_kind != 'realm' and not realm: module.fail_json(msg="Parameter 'realm' is required for non-realm objects.") api, eff_lookup_field = get_api_and_lookup_field(object_kind, lookup_field) if not api: module.fail_json(msg="Unsupported object_kind", object_kind=object_kind) object_id, exists = resolve_object_id( module, object_kind, api, eff_lookup_field, lookup_value, realm, kcadm_exec ) result['object_exists'] = exists result['object_id'] = object_id # CREATE PATH (no existing object) if not exists or not object_id: desired_obj = deepcopy(desired) # Drop unsupported fields for components (e.g. subComponents) if object_kind == 'component': desired_obj.pop('subComponents', None) # Apply forced attributes (common behavior) if force_attrs: desired_obj = deep_merge(desired_obj, force_attrs) rc, out, err = send_create( module, object_kind, api, realm, kcadm_exec, desired_obj ) if rc != 0: module.fail_json( msg="Failed to create Keycloak object", rc=rc, stdout=out, stderr=err, payload=desired_obj ) result['changed'] = True result['result'] = desired_obj module.exit_json(**result) # UPDATE PATH (object exists) cur_obj = get_current_object(module, object_kind, api, object_id, realm, kcadm_exec) # Optional safety check: providerId must match for components if assert_mode and object_kind == 'component': cur_provider = cur_obj.get('providerId', '') des_provider = desired.get('providerId', '') if cur_provider and des_provider and cur_provider != des_provider: module.fail_json( msg="Refusing to update component due to providerId mismatch", current_providerId=cur_provider, desired_providerId=des_provider, ) # Build merge payload (full or subpath) if merge_path: merge_payload = { merge_path: deepcopy(desired.get(merge_path, {})) } else: merge_payload = deepcopy(desired) desired_obj = deep_merge(cur_obj, merge_payload) # Preserve immutable fields if object_kind == 'client': for k in ['id', 'clientId']: if k in cur_obj: desired_obj[k] = cur_obj[k] elif object_kind == 'component': for k in ['id', 'providerId', 'providerType', 'parentId']: if k in cur_obj: desired_obj[k] = cur_obj[k] # Drop unsupported fields such as subComponents desired_obj.pop('subComponents', None) elif object_kind == 'client-scope': for k in ['id', 'name']: if k in cur_obj: desired_obj[k] = cur_obj[k] elif object_kind == 'realm': for k in ['id', 'realm']: if k in cur_obj: desired_obj[k] = cur_obj[k] # Apply forced attributes (last) if force_attrs: desired_obj = deep_merge(desired_obj, force_attrs) # If nothing changed logically, we could diff & short-circuit. # For simplicity we always send update and rely on Keycloak to no-op. rc, out, err = send_update( module, object_kind, api, object_id, realm, kcadm_exec, desired_obj ) if rc != 0: module.fail_json( msg="Failed to update Keycloak object", rc=rc, stdout=out, stderr=err, payload=desired_obj ) result['changed'] = True result['result'] = desired_obj module.exit_json(**result) def main(): run_module() if __name__ == '__main__': main()