Files
kevinveenbirkenbachandClaude Opus 5 a46348b6cf refactor(handlers): bind notify to a listen topic, not to a name
A handler name was doing two jobs at once: the line an operator reads in the
play output, and the identifier a `notify:` binds to. The two pull in opposite
directions -- the log wants "restart the SSH service, with diagnostics", the
binding wants a token that survives quoting, YAML flow style and a shell
round-trip -- and a name that changes for readability silently changes which
handler runs.

Every handler that a notify reached by name now carries a `listen:` with that
exact wording, so the binding is explicit before anything is renamed. The
topics themselves become identifiers: `compose up` reads `compose-up`,
`Restart NFS ganesha` reads `restart-nfs-ganesha`, 22 of them across both
sides of every notify. Where the topic is assembled at runtime, both branches
moved together: the conditional in sys-service carries two literals, and the
LDAP renderer interpolates `import-{{ folder }}-ldif-files`, which is what
makes those handlers reachable at all.

Freed from the binding, the names take the emoji prefix every other task line
already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:42 +02:00

231 lines
7.9 KiB
YAML

---
- name: "🔄 Update REALM settings (merge LDAP component .config)"
keycloak_kcadm_update:
object_kind: "component"
lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
desired: >-
{{
(
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| list | first
)
}}
merge_path: "config"
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
realm: "{{ KEYCLOAK_REALM }}"
assert_mode: "{{ MODE_ASSERT }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: ⬇️ Fetch components (JSON)
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get components -r {{ KEYCLOAK_REALM }} --format json
register: kc_components
changed_when: false
- name: 🔍 Resolve LDAP component id (client-side filter; avoid unreliable --query)
set_fact:
ldap_cmp_id: >-
{{
(
kc_components.stdout | kcadm_json
| selectattr('providerType','equalto','org.keycloak.storage.UserStorageProvider')
| selectattr('providerId','equalto','ldap')
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| map(attribute='id')
| list
| first
| default('')
) | string | trim
}}
changed_when: false
- name: ✅ Assert LDAP component id resolved
assert:
that: ["ldap_cmp_id not in ['', 'null']"]
fail_msg: "LDAP component '{{ KEYCLOAK_LDAP_CMP_NAME }}' not found in Keycloak."
when: MODE_ASSERT_LOCAL | bool
- name: ⬇️ Pull LDAP component from dictionary (by name)
set_fact:
ldap_component_tpl: >-
{{
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| list | first | default({})
}}
changed_when: false
- name: ✅ Sanity check dictionary LDAP component
assert:
that:
- ldap_component_tpl | length > 0
- (ldap_component_tpl.subComponents | default({})) | length > 0
fail_msg: "LDAP component '{{ KEYCLOAK_LDAP_CMP_NAME }}' not found in KEYCLOAK_DICTIONARY_REALM."
when: MODE_ASSERT_LOCAL | bool
- name: 📤 Extract mapper 'ldap-roles' from template (raw)
set_fact:
desired_group_mapper_raw: >-
{{
(
ldap_component_tpl.subComponents['org.keycloak.storage.ldap.mappers.LDAPStorageMapper']
| default([])
)
| selectattr('name','equalto','ldap-roles')
| list | first | default({})
}}
changed_when: false
- name: ✅ Ensure mapper exists in dictionary
assert:
that: ["desired_group_mapper_raw | length > 0"]
fail_msg: "'ldap-roles' mapper not found in dictionary under LDAP component."
when: MODE_ASSERT_LOCAL | bool
- name: 🔨 Build clean mapper payload (drop id/subComponents)
set_fact:
desired_group_mapper: >-
{{
desired_group_mapper_raw
| dict2items
| rejectattr('key','in',['subComponents','id'])
| list | items2dict
}}
changed_when: false
- name: 📤 Extract desired groups.path (if any)
set_fact:
desired_groups_path: "{{ (desired_group_mapper.config['groups.path'] | default([])) | first | default('') | trim }}"
desired_groups_top: "{{ ((desired_group_mapper.config['groups.path'] | default([])) | first | default('') | trim).lstrip('/') | split('/') | first | default('')
}}"
changed_when: false
- name: ✅ Ensure groups.path.group is correct (only when groups.path is configured)
include_tasks: "{{ role_path }}/tasks/update/ldap/groups_path.yml"
when: desired_groups_top | length > 0
- name: 🔍 List 'ldap-roles' mapper ids under LDAP component (client-side filter)
set_fact:
grp_mapper_ids: >-
{{
(
kc_components.stdout | kcadm_json
| selectattr('parentId','equalto', ldap_cmp_id)
| selectattr('providerId','equalto','group-ldap-mapper')
| selectattr('providerType','equalto','org.keycloak.storage.ldap.mappers.LDAPStorageMapper')
| selectattr('name','equalto','ldap-roles')
| map(attribute='id')
| list
| map('string')
| map('trim')
| reject('equalto','')
| list
| sort
)
}}
changed_when: false
- name: 🗑️ Delete duplicate 'ldap-roles' mappers (keep canonical first)
when: grp_mapper_ids | length > 1
shell: >
{{ KEYCLOAK_EXEC_KCADM }} delete components/{{ item }} -r {{ KEYCLOAK_REALM }}
loop: "{{ grp_mapper_ids[1:] }}"
register: del_dupes
changed_when: del_dupes.rc == 0
failed_when: del_dupes.rc != 0
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: 🔄 Refresh components (JSON) after dedupe (authoritative)
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get components -r {{ KEYCLOAK_REALM }} --format json
register: kc_components_after_dedupe
changed_when: false
- name: 🔧 Set canonical mapper id (after dedupe)
set_fact:
grp_mapper_id: >-
{{
(
kc_components_after_dedupe.stdout | kcadm_json
| selectattr('parentId','equalto', ldap_cmp_id)
| selectattr('providerId','equalto','group-ldap-mapper')
| selectattr('providerType','equalto','org.keycloak.storage.ldap.mappers.LDAPStorageMapper')
| selectattr('name','equalto','ldap-roles')
| map(attribute='id')
| list
| map('string')
| map('trim')
| reject('equalto','')
| list
| sort
| first
| default('')
) | string | trim
}}
changed_when: false
- name: "📁 Create canonical 'ldap-roles' mapper when missing"
when: (grp_mapper_id | trim) in ["", "null"]
block:
- name: 📁 Create 'ldap-roles' mapper if missing
ansible.builtin.shell: >-
printf '%s' {{ (desired_group_mapper | ldap_roles_mapper_payload(ldap_cmp_id | trim)) | quote }}
| {{ KEYCLOAK_EXEC_KCADM }} create components -r {{ KEYCLOAK_REALM }} -f -
register: create_mapper
changed_when: create_mapper.rc == 0
failed_when: create_mapper.rc != 0 and ('already exists' not in (create_mapper.stderr | lower))
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: 🔄 Refresh components (JSON) after create (authoritative)
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get components -r {{ KEYCLOAK_REALM }} --format json
register: kc_components_after_create
changed_when: false
- name: 🔧 Set canonical mapper id after create
set_fact:
grp_mapper_id: >-
{{
(
kc_components_after_create.stdout | kcadm_json
| selectattr('parentId','equalto', ldap_cmp_id)
| selectattr('providerId','equalto','group-ldap-mapper')
| selectattr('providerType','equalto','org.keycloak.storage.ldap.mappers.LDAPStorageMapper')
| selectattr('name','equalto','ldap-roles')
| map(attribute='id')
| list
| map('string')
| map('trim')
| reject('equalto','')
| list
| sort
| first
| default('')
) | string | trim
}}
changed_when: false
- name: 🔄 Update 'ldap-roles' mapper config (merge only .config)
when: (grp_mapper_id | trim) not in ["", "null"]
keycloak_kcadm_update:
object_kind: "component"
lookup_field: "id"
lookup_value: "{{ grp_mapper_id | trim }}"
desired: >-
{{
desired_group_mapper
| combine({
'name': 'ldap-roles',
'parentId': (ldap_cmp_id | trim),
'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper',
'providerId': 'group-ldap-mapper'
}, recursive=True)
}}
merge_path: "config"
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
realm: "{{ KEYCLOAK_REALM }}"
assert_mode: "{{ MODE_ASSERT }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
notify: sync-ldap-groups