Files
computer-playbook/roles/svc-db-openldap/tasks/04_users.yml
kevinveenbirkenbachandClaude Opus 5 cd1225d2d9 fix(async): collect fire-and-forget results
The repo's async convention is fire and forget: ASYNC_POLL is 0 whenever
ASYNC_ENABLED is true, and the swarm test lane forces it true through
extra vars. A task dispatched that way returns a stub, so a changed_when
or failed_when reading its own register evaluates against nothing and can
never report changed and never fail. Several tasks carried a
"not ASYNC_ENABLED and" prefix in front of those conditions, added to
avoid touching undefined rc and stdout, which turned the guard off
entirely rather than adapting it.

That is how the nextcloud plugin configuration stayed broken unseen: the
batch reported ok after 0.8s while its delegated commands kept running,
the next task read the half written configuration, and no failure in any
of them could surface.

Every async in the repo was decided individually rather than swept. Where
nothing consumes the result, the async stays and a one line rationale now
records what does not read it. Where a later task, a handler or a browser
does consume it, either the async goes or the result is collected.

Collecting is what the new reaper does. It waits for a whole batch, then
reports every broken job at once instead of aborting on the first, so one
run shows whether a single item or the whole set is broken. Deciding what
counts as broken lives in a filter plugin, because the distinction is
subtle enough to deserve tests: a module reports failure in msg, a command
in rc and stdout, an idempotent API answers "already exists" and means
success, and a job that cannot be read at all comes back marked finished
with no outcome, which would otherwise read as green.

Timing cost is roughly one to two minutes on a deploy measured in hours.

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

182 lines
6.3 KiB
YAML

---
# ##############################################################################
#
# Purpose:
# - Ensure base LDAP tree exists (suffix + OUs)
# - Ensure LDAP user entries exist (without setting userPassword here)
# - Set user passwords:
# * default: on every deploy
# * optional per-user override: set once on create via password_update: false
# - Keep objectClass list and mail attribute up to date
# ##############################################################################
# ##############################################################################
# 0) Ensure base tree exists (suffix + OUs)
# ##############################################################################
- name: ✅ Ensure LDAP root (suffix) entry exists
community.general.ldap_entry:
dn: "{{ LDAP.DN.ROOT }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
objectClass:
- top
- dcObject
- organization
attributes:
dc: "{{ (LDAP.DN.ROOT | regex_search('dc=([^,]+)', '\\1')) | first | default('infinito') }}"
o: "{{ (LDAP.DN.ROOT | regex_search('dc=([^,]+)', '\\1')) | first | default('infinito') }}"
state: present
- name: ✅ Ensure people OU exists
community.general.ldap_entry:
dn: "{{ LDAP.DN.OU.USERS }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
objectClass:
- top
- organizationalUnit
attributes:
ou: users
description: "Users container"
state: present
- name: ✅ Ensure groups OU exists
community.general.ldap_entry:
dn: "{{ LDAP.DN.OU.GROUPS }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
objectClass:
- top
- organizationalUnit
attributes:
ou: groups
description: "Groups container"
state: present
- name: ✅ Ensure roles OU exists
community.general.ldap_entry:
dn: "{{ LDAP.DN.OU.ROLES }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
objectClass:
- top
- organizationalUnit
attributes:
ou: roles
description: "Container for application access profiles"
state: present
- name: 🔎 Check whether LDAP users already exist
community.general.ldap_search:
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
dn: "{{ LDAP.USER.ATTRIBUTES.ID }}={{ item.key }},{{ LDAP.DN.OU.USERS }}"
scope: base
filter: "(objectClass=*)"
attrs:
- dn
loop: "{{ OPENLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"
register: openldap_users_existence_before
changed_when: false
failed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: 🔨 Build map of users that existed before this run
ansible.builtin.set_fact:
openldap_user_existed_before: >-
{{
(openldap_user_existed_before | default({}))
| combine({
item.item.key: ((item.results | default([])) | length > 0)
})
}}
loop: "{{ openldap_users_existence_before.results }}"
loop_control:
label: "{{ item.item.key }}"
- name: ✅ Ensure LDAP users exist
community.general.ldap_entry:
dn: "{{ LDAP.USER.ATTRIBUTES.ID }}={{ item.key }},{{ LDAP.DN.OU.USERS }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
objectClass: "{{ LDAP.USER.OBJECTS.STRUCTURAL }}"
attributes:
uid: "{{ item.value.username }}"
givenName: "{{ item.value.firstname }}"
sn: "{{ item.value.lastname }}"
cn: "{{ item.value.cn | default(item.key) }}"
loginShell: /bin/bash
homeDirectory: "/home/{{ item.key }}"
uidNumber: "{{ item.value.uid | int }}"
gidNumber: "{{ item.value.gid | int }}"
state: present
register: openldap_users_created
changed_when: false
# rationale: async; user creation runs overlapped, reaped right below before the password task
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
throttle: 1
loop: "{{ OPENLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "⏳ Reap the LDAP user creation jobs"
ansible.builtin.include_tasks: utils/async/reap.yml
vars:
async_reap_jobs: "{{ openldap_users_created.results | default([]) }}"
async_reap_label: "LDAP users"
async_reap_tolerate: []
- name: 🔧 Set LDAP user password (per-user update policy)
community.general.ldap_passwd:
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
dn: "{{ LDAP.USER.ATTRIBUTES.ID }}={{ item.key }},{{ LDAP.DN.OU.USERS }}"
passwd: "{{ item.value.password }}"
loop: "{{ OPENLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"
when:
- >
(item.value.password_update | default(true) | bool)
or not ((openldap_user_existed_before | default({})).get(item.key, false) | bool)
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: ✅ Ensure required objectClass values and mail address are present
community.general.ldap_attrs:
dn: "{{ LDAP.USER.ATTRIBUTES.ID }}={{ item.key }},{{ LDAP.DN.OU.USERS }}"
server_uri: "{{ OPENLDAP_SERVER_URI }}"
bind_dn: "{{ OPENLDAP_BIND_DN }}"
bind_pw: "{{ OPENLDAP_BIND_PW }}"
attributes:
objectClass: "{{ LDAP.USER.OBJECTS.STRUCTURAL }}"
mail: "{{ item.value.email }}"
state: "{{ 'exact' if MODE_CLEANUP | bool else 'present' }}"
register: openldap_users_attrs
changed_when: false
# rationale: async; attribute updates run overlapped, reaped at the end of this file
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
throttle: 1
loop: "{{ OPENLDAP_USERS | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "⏳ Reap the LDAP attribute jobs"
ansible.builtin.include_tasks: utils/async/reap.yml
vars:
async_reap_jobs: "{{ openldap_users_attrs.results | default([]) }}"
async_reap_label: "LDAP user attributes"
async_reap_tolerate: []