mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
495 lines
16 KiB
Python
Executable File
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()
|