Files
computer-playbook/library/keycloak_kcadm_update.py
kevinveenbirkenbachandClaude Opus 5.5 30084f986c fix(web-app-keycloak): keep concurrent kcadm jobs from losing the config lock
In the async pass the role runs kcadm jobs side by side. Every kcadm call in
the Keycloak container shares one config file. kcadm rewrites that file when
it refreshes the access token, about every 55 seconds, takes the file lock
with tryLock() and gives up at once when another kcadm process holds it. One
of two overlapping declared-client jobs then failed with "Failed to get lock
on /opt/keycloak/.keycloak/kcadm.config", and the deploy with it.

keycloak_kcadm_update now repeats a call that lost the lock, up to ten times
one second apart. The call fails while it refreshes its token, before it
sends its own request, so repeating it is safe. Every other error still ends
the call at once.

That covers the module only, so the async jobs are reduced to the ones that
go through it:

- The default-scope assignment was an async shell task. It ran beside the
  shell tasks that follow it, and neither side repeats a lost lock. It runs
  in line now.
- The two mail-settings jobs were async without a register, so nothing
  reaped them and a failure was never seen. They run in line now.

The client job and the declared-client jobs stay async. A test fails when an
async kcadm task does not use the module or is not registered for the reap.

A config file of its own per call was the other option. It does not work by
itself, because the admin session lives only in the shared file.

Closes #805

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:17:56 +02:00

495 lines
16 KiB
Python
Executable File

#!/usr/bin/python
import json
import re
import subprocess
import time
from copy import deepcopy
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.kcadm_json import json_from_noisy_stdout
from ansible.module_utils.keycloak_scopes import converge_client_scope_lists
NO_SUCH_CONTAINER_RE = re.compile(r"no such container", re.IGNORECASE)
DEAD_CID_MAX_RETRIES = 80
DEAD_CID_RETRY_DELAY = 3
CONFIG_LOCK_RE = re.compile(r"Failed to get lock on")
CONFIG_LOCK_MAX_RETRIES = 10
CONFIG_LOCK_RETRY_DELAY = 1
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(container 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
cid_resolve_cmd:
description:
- Swarm-only. Shell command that prints a fresh, live, healthy container id.
- When a kcadm exec aborts with 'No such container' (the cached swarm id
was rescheduled mid-block), this command is re-run to obtain a live id
and the exec is retried.
type: str
required: False
current_cid:
description:
- Swarm-only. The container id currently embedded in C(kcadm_exec).
- Used as the literal token to swap for a freshly resolved id on retry.
type: str
required: False
author:
- Your Name
"""
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
"""
def _shell(cmd, check, lock_retries=CONFIG_LOCK_MAX_RETRIES):
rc = subprocess.run( # noqa: S602 - host-trusted argv; kcadm wrapper needs shell pipes/&&
cmd,
shell=True,
check=check,
capture_output=True,
)
stdout = rc.stdout.decode("utf-8", errors="replace").strip()
stderr = rc.stderr.decode("utf-8", errors="replace").strip()
if rc.returncode != 0 and lock_retries and CONFIG_LOCK_RE.search(stderr):
time.sleep(CONFIG_LOCK_RETRY_DELAY)
return _shell(cmd, check, lock_retries - 1)
return rc.returncode, stdout, stderr
def _resolve_live_cid(module):
"""Resolve a fresh, healthy container id via the swarm-service resolver.
Returns the new id or "" when no resolver hint was provided or the
resolver yielded nothing.
"""
state = getattr(module, "_kcadm_cid_state", None)
if not state or not state.get("cid_resolve_cmd"):
return ""
_rc, out, _err = _shell(state["cid_resolve_cmd"], check=False)
return (out or "").strip()
def run_kcadm(module, cmd, ignore_rc=False):
"""Run a shell command for kcadm.
In swarm the keycloak service runs multiple replicas that swarm may
reschedule between any two of the sequential execs this module issues,
so a container id resolved earlier can die mid-block. When that happens
`container exec` aborts with 'No such container' BEFORE kcadm runs; if a
resolver hint is present, re-resolve a live healthy id, splice it into
the command and retry (bounded) instead of failing the whole client
update on an ephemeral id.
"""
state = getattr(module, "_kcadm_cid_state", None)
attempt_cmd = cmd
for attempt in range(DEAD_CID_MAX_RETRIES + 1):
rc, stdout, stderr = _shell(attempt_cmd, check=False)
retryable = (
rc != 0
and state
and state.get("cid_resolve_cmd")
and state.get("current_cid")
and NO_SUCH_CONTAINER_RE.search(stderr or "")
and attempt < DEAD_CID_MAX_RETRIES
)
if not retryable:
if rc != 0 and not ignore_rc:
raise subprocess.CalledProcessError(rc, attempt_cmd, stdout, stderr)
return rc, stdout, stderr
time.sleep(DEAD_CID_RETRY_DELAY)
new_cid = _resolve_live_cid(module)
if not new_cid or new_cid == state["current_cid"]:
continue
old_cid = state["current_cid"]
attempt_cmd = cmd.replace(old_cid, new_cid)
cmd = attempt_cmd
state["current_cid"] = new_cid
return rc, stdout, stderr
def deep_merge(a, b):
"""Recursive dict merge similar to Ansible's combine(recursive=True)."""
result = deepcopy(a)
for k, v in (b or {}).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":
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
scopes = json_from_noisy_stdout(out)
for obj in scopes:
if obj.get(lookup_field) == lookup_value:
return obj.get("id", ""), True
return "", False
if object_kind == "client":
cmd = f"{kcadm_exec} get clients -r {realm} --format json"
rc, out, _err = run_kcadm(module, cmd, ignore_rc=True)
if rc != 0 or not out:
return "", False
clients = json_from_noisy_stdout(out)
for obj in clients:
if obj.get(lookup_field) == lookup_value:
return obj.get("id", ""), True
return "", False
if object_kind == "component":
cmd = f"{kcadm_exec} get components -r {realm} --format json"
rc, out, _err = run_kcadm(module, cmd, ignore_rc=True)
if rc != 0 or not out:
return "", False
comps = json_from_noisy_stdout(out)
for obj in comps:
if obj.get(lookup_field) == lookup_value:
return obj.get("id", ""), True
return "", False
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
data = json_from_noisy_stdout(out)
if not data:
return "", False
return data[0].get("id", ""), True
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)
return json_from_noisy_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"
f"{payload_json}\nJSON"
)
else:
cmd = (
f"cat <<'JSON' | {kcadm_exec} update {api}/{object_id} -r {realm} -f -\n"
f"{payload_json}\nJSON"
)
return run_kcadm(module, cmd, ignore_rc=True)
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"
f"{payload_json}\nJSON"
)
return run_kcadm(module, cmd, ignore_rc=True)
def run_module():
module_args = {
"object_kind": {"type": "str", "required": True},
"lookup_value": {"type": "str", "required": True},
"desired": {"type": "dict", "required": True},
"lookup_field": {"type": "str", "required": False, "default": None},
"merge_path": {"type": "str", "required": False, "default": None},
"force_attrs": {"type": "dict", "required": False, "default": None},
"kcadm_exec": {"type": "str", "required": True},
"realm": {"type": "str", "required": False, "default": None},
"assert_mode": {"type": "bool", "required": False, "default": True},
"cid_resolve_cmd": {"type": "str", "required": False, "default": None},
"current_cid": {"type": "str", "required": False, "default": None},
}
result = {
"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"]
module._kcadm_cid_state = {
"cid_resolve_cmd": module.params.get("cid_resolve_cmd"),
"current_cid": (module.params.get("current_cid") or "").strip(),
}
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
if not exists or not object_id:
desired_obj = deepcopy(desired)
if object_kind == "component":
desired_obj.pop("subComponents", None)
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:
err_l = (err or "").lower()
already_exists_markers = ["already exists", "exists with same", "conflict"]
if any(m in err_l for m in already_exists_markers):
object_id2, exists2 = resolve_object_id(
module,
object_kind,
api,
eff_lookup_field,
lookup_value,
realm,
kcadm_exec,
)
if exists2 and object_id2:
object_id = object_id2
exists = True
result["object_exists"] = True
result["object_id"] = object_id
else:
module.fail_json(
msg="Create failed with 'already exists' but re-resolve did not find object id",
rc=rc,
stdout=out,
stderr=err,
payload=desired_obj,
)
else:
module.fail_json(
msg="Failed to create Keycloak object",
rc=rc,
stdout=out,
stderr=err,
payload=desired_obj,
)
if not exists or not object_id:
result["changed"] = True
result["result"] = desired_obj
module.exit_json(**result)
cur_obj = get_current_object(module, object_kind, api, object_id, realm, kcadm_exec)
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,
)
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)
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]
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]
if force_attrs:
desired_obj = deep_merge(desired_obj, force_attrs)
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,
)
if object_kind == "client":
converge_client_scope_lists(
run_kcadm, module, object_id, desired, realm, kcadm_exec
)
result["changed"] = True
result["result"] = desired_obj
module.exit_json(**result)
def main():
run_module()
if __name__ == "__main__":
main()