From b434dbc74af372e26f3ed12c47a035b31c8a7b86 Mon Sep 17 00:00:00 2001 From: Anders Andersson Date: Thu, 27 Mar 2025 11:51:21 +0100 Subject: [PATCH 1/4] Feature: Add safe initial synchronization support for new replication peers --- README.md | 37 ++++++++++++++++ defaults/main.yml | 6 +++ tasks/configure_replication_agreement.yml | 11 ++++- tasks/enable_outbound_replication.yml | 30 +++++++++++++ tasks/main.yml | 14 ++++++ tasks/perform_initial_sync.yml | 54 +++++++++++++++++++++++ tasks/restart_service.yml | 10 +++++ 7 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tasks/enable_outbound_replication.yml create mode 100644 tasks/perform_initial_sync.yml create mode 100644 tasks/restart_service.yml diff --git a/README.md b/README.md index f9304f0..8c28dc2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ If Ansible does not support the `ldap_attrs` module you're using an old version | dirsrv_replication_user_password | | Password for that account. | Yes | CB | | dirsrv_begin_replication_immediately | true | Boolean, sets `nsds5ReplicaEnabled` to "on" or "off" in the replication agreement. This should be a safe: if you add a new server it won't start pushing its empty database to other servers in any case because they have a different generation ID and replication fails (see examples for more details), but if you want to be even safer or make some customizations to the replication agreement, set this to false | **No** | CB | | dirsrv_consumer_referral_to_supplier | "ldap://supplier.example.com:389/" | Full LDAP URI including port. When a client tries to write to a consumer, which is read-only, it will redirect the client to this server (a supplier that can accept writes). | Yes | C | +| dirsrv_initial_sync_source | "" | FQDN of the server to initially sync from. If specified, this server will initiate a replica refresh to push data to the new server before enabling bidirectional replication. Leave empty to skip initial sync. | Yes | SB | +| dirsrv_wait_for_initial_sync | true | Whether to wait for the initial sync to complete before proceeding. Set to false for very large databases where you want to continue with the playbook and handle sync completion separately. | Yes | SB | +| dirsrv_initial_sync_timeout | 600 | Timeout in seconds to wait for initial sync to complete before giving up. | Yes | SB | +| dirsrv_enable_outbound_replication_after_sync | true | After initial sync completes, automatically enable outbound replication from this server back to the source. This ensures bidirectional replication only begins after the new server has received a complete copy of the data. | Yes | SB | First of all choose if the server is a supplier, consumer or both and set dirsv_role accordingly. Then set the variables related to that: look in the Role @@ -167,6 +171,39 @@ to the consumer: dirsrv_supplier_replica_id: 2 ``` +## Initial Synchronization in Multi-Master Setup + +When adding a new server to an existing multi-master replication setup, it's important to ensure the new server receives a complete copy of the data before it starts replicating back to existing servers. This prevents the new server from potentially replicating an empty directory back to existing servers. + +To safely add a new server: + +1. Set `dirsrv_initial_sync_source` to the FQDN of an existing server containing the data +2. The role will: + - Configure the new server with replication agreements but initially disable outbound replication + - Trigger a replica refresh from the source server to the new server + - Wait for the initial sync to complete (controlled by `dirsrv_wait_for_initial_sync` and `dirsrv_initial_sync_timeout`) + - Enable outbound replication from the new server back to the source (if `dirsrv_enable_outbound_replication_after_sync` is true) + +Example for adding a new server to an existing multi-master setup: + +```yaml +- hosts: new_ldap_server + become: true + roles: + - + role: lvps.389ds_replication + dirsrv_replica_role: 'both' + dirsrv_suffix: "dc=example,dc=local" + dirsrv_server_uri: "ldap://localhost" + dirsrv_rootdn_password: secret + dirsrv_replication_user_password: "password1" + dirsrv_replication_user_password_remote: "password2" + dirsrv_consumer_uri: "ldap://existing_server.example.local:389/" + dirsrv_supplier_replica_id: 3 + # Enable safe initial sync + dirsrv_initial_sync_source: "existing_server.example.local" +``` + ## Known bugs If `dirsrv_replication_user_password` is changed, no change is reported: this diff --git a/defaults/main.yml b/defaults/main.yml index 923c2ce..d1982f8 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -34,3 +34,9 @@ dirsrv_replication_user: Replication Manager # dirsrv_replication_user_password: even-more-secret dirsrv_begin_replication_immediately: true # dirsrv_consumer_referral_to_supplier: "ldap://supplier.example.com:389/" + +# For controlling initial synchronization +dirsrv_initial_sync_source: "" # FQDN of the server to initially sync from (if empty, no initial sync) +dirsrv_wait_for_initial_sync: true # Whether to wait for initial sync to complete +dirsrv_initial_sync_timeout: 600 # Timeout in seconds to wait for initial sync +dirsrv_enable_outbound_replication_after_sync: true # Enable outbound replication after sync completes \ No newline at end of file diff --git a/tasks/configure_replication_agreement.yml b/tasks/configure_replication_agreement.yml index ac65b97..d54f520 100644 --- a/tasks/configure_replication_agreement.yml +++ b/tasks/configure_replication_agreement.yml @@ -4,6 +4,15 @@ # When setting up replication agreements, first set them up between all suppliers, then between the # suppliers and the hubs, and last between the hub and the consumers. +- name: Determine if outbound replication should be enabled initially + ansible.builtin.set_fact: + dirsrv_outbound_replication_enabled: "{{ + 'on' if + (dirsrv_begin_replication_immediately and not dirsrv_initial_sync_source) or + (not dirsrv_initial_sync_source) + else 'off' + }}" + - name: Set agreement name ansible.builtin.set_fact: dirsrv_agreement_cn: "agreement_with_{{ dirsrv_consumer_uri | urlsplit('hostname') | replace('.', '_') }}" @@ -34,7 +43,7 @@ description: "Agreement between this server (supplier) and {{ dirsrv_consumer_uri }} (consumer). Managed by Ansible." nsds5replicatedattributelist: "{{ dirsrv_replica_attributes_list }}" nsds5ReplicatedAttributeListTotal: "{{ dirsrv_replica_attributes_list_total }}" - nsds5ReplicaEnabled: "{{ 'on' if dirsrv_begin_replication_immediately else 'off' }}" + nsds5ReplicaEnabled: "{{ dirsrv_outbound_replication_enabled }}" state: present - name: Configure replication agreement on supplier diff --git a/tasks/enable_outbound_replication.yml b/tasks/enable_outbound_replication.yml new file mode 100644 index 0000000..714bae6 --- /dev/null +++ b/tasks/enable_outbound_replication.yml @@ -0,0 +1,30 @@ +--- +- name: Create temporary LDIF file for enabling outbound replication + ansible.builtin.copy: + dest: "/tmp/dirsrv_enable_replication.ldif" + content: | + dn: cn=agreement_with_{{ dirsrv_consumer_uri | urlsplit('hostname') | replace('.', '_') }},cn=replica,cn="{{ dirsrv_suffix }}",cn=mapping tree,cn=config + changetype: modify + replace: nsds5ReplicaEnabled + nsds5ReplicaEnabled: on + mode: '0600' + when: dirsrv_perform_initial_sync and dirsrv_enable_outbound_replication_after_sync + +- name: Enable outbound replication after sync + ansible.builtin.shell: > + ldapmodify + -x + {{ '-ZZ' if dirsrv_use_starttls else '' }} + -H {{ dirsrv_server_uri }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" + -f /tmp/dirsrv_enable_replication.ldif + register: enable_result + failed_when: enable_result.rc != 0 + when: dirsrv_perform_initial_sync and dirsrv_enable_outbound_replication_after_sync + +- name: Remove temporary LDIF file for enabling replication + ansible.builtin.file: + path: "/tmp/dirsrv_enable_replication.ldif" + state: absent + when: dirsrv_perform_initial_sync and dirsrv_enable_outbound_replication_after_sync diff --git a/tasks/main.yml b/tasks/main.yml index 38839d2..3157d32 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -37,3 +37,17 @@ - name: Supplier or master/both ansible.builtin.include_tasks: configure_replication_agreement.yml when: dirsrv_instance_is_supplier + +- name: Perform initial sync if source server is specified + ansible.builtin.include_tasks: perform_initial_sync.yml + when: dirsrv_instance_is_supplier and dirsrv_initial_sync_source | length > 0 + +- name: Enable outbound replication after initial sync + ansible.builtin.include_tasks: enable_outbound_replication.yml + when: dirsrv_instance_is_supplier and dirsrv_initial_sync_source | length > 0 and dirsrv_enable_outbound_replication_after_sync + +- name: Restart service after replication agreement creation + ansible.builtin.include_tasks: restart_service.yml + when: + - dirsrv_instance_is_supplier or dirsrv_instance_is_consumer + - dirsrv_initial_sync_source | length > 0 and dirsrv_enable_outbound_replication_after_sync diff --git a/tasks/perform_initial_sync.yml b/tasks/perform_initial_sync.yml new file mode 100644 index 0000000..45ac83c --- /dev/null +++ b/tasks/perform_initial_sync.yml @@ -0,0 +1,54 @@ +--- +- name: Check if initial sync is required + ansible.builtin.set_fact: + dirsrv_perform_initial_sync: "{{ dirsrv_initial_sync_source | length > 0 }}" + +- name: Create temporary LDIF file for initiating sync + ansible.builtin.copy: + dest: "/tmp/dirsrv_initial_sync.ldif" + content: | + dn: cn=agreement_with_{{ ansible_hostname + "_" + ansible_domain | replace('.', '_') }},cn=replica,cn="{{ dirsrv_suffix }}",cn=mapping tree,cn=config + changetype: modify + add: nsds5BeginReplicaRefresh + nsds5BeginReplicaRefresh: start + mode: '0600' + delegate_to: "{{ dirsrv_initial_sync_source }}" + when: dirsrv_perform_initial_sync + +- name: Trigger initial sync from source server + ansible.builtin.shell: > + ldapmodify + -x + {{ '-ZZ' if dirsrv_use_starttls else '' }} + -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" + -f /tmp/dirsrv_initial_sync.ldif + delegate_to: "{{ dirsrv_initial_sync_source }}" + register: sync_result + failed_when: sync_result.rc != 0 and "Replica is being initialized" not in sync_result.stderr + when: dirsrv_perform_initial_sync + +- name: Remove temporary LDIF file + ansible.builtin.file: + path: "/tmp/dirsrv_initial_sync.ldif" + state: absent + delegate_to: "{{ dirsrv_initial_sync_source }}" + when: dirsrv_perform_initial_sync + +- name: Wait for initial sync to complete + ansible.builtin.shell: > + ldapsearch + -x + {{ '-ZZ' if dirsrv_use_starttls else '' }} + -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" + -b "cn=agreement_with_{{ ansible_hostname + "_" + ansible_domain | replace('.', '_') }},cn=replica,cn={{ dirsrv_suffix | replace('=','\=') | replace(',','\,') }},cn=mapping tree,cn=config" + nsds5replicaUpdateInProgress | grep "nsds5replicaUpdateInProgress:" | awk '{print $2}' + register: sync_status + until: sync_status.stdout.strip() == "FALSE" + retries: "{{ (dirsrv_initial_sync_timeout / 10) | int }}" + delay: 10 + delegate_to: "{{ dirsrv_initial_sync_source }}" + when: dirsrv_perform_initial_sync and dirsrv_wait_for_initial_sync diff --git a/tasks/restart_service.yml b/tasks/restart_service.yml new file mode 100644 index 0000000..b296724 --- /dev/null +++ b/tasks/restart_service.yml @@ -0,0 +1,10 @@ +- name: restart local 389ds + ansible.builtin.service: + name: dirsrv@{{ dirsrv_serverid }} + state: restarted + +- name: restart remote consumer 389ds + ansible.builtin.service: + name: dirsrv@{{ dirsrv_serverid }} + state: restarted + delegate_to: "{{ dirsrv_consumer_uri | urlsplit('hostname') }}" \ No newline at end of file From d20af5c238f6c3bb78304d5cb6967ba58ddcb5c7 Mon Sep 17 00:00:00 2001 From: Anders Andersson Date: Thu, 9 Apr 2026 14:44:31 +0200 Subject: [PATCH 2/4] Lint --- defaults/main.yml | 2 +- tasks/configure_replication_agreement.yml | 10 +++++----- tasks/enable_outbound_replication.yml | 8 ++++---- tasks/main.yml | 7 ++++--- tasks/perform_initial_sync.yml | 20 ++++++++++---------- tasks/restart_service.yml | 3 ++- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/defaults/main.yml b/defaults/main.yml index d1982f8..94c9d2a 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -39,4 +39,4 @@ dirsrv_begin_replication_immediately: true dirsrv_initial_sync_source: "" # FQDN of the server to initially sync from (if empty, no initial sync) dirsrv_wait_for_initial_sync: true # Whether to wait for initial sync to complete dirsrv_initial_sync_timeout: 600 # Timeout in seconds to wait for initial sync -dirsrv_enable_outbound_replication_after_sync: true # Enable outbound replication after sync completes \ No newline at end of file +dirsrv_enable_outbound_replication_after_sync: true # Enable outbound replication after sync completes diff --git a/tasks/configure_replication_agreement.yml b/tasks/configure_replication_agreement.yml index d54f520..41b413a 100644 --- a/tasks/configure_replication_agreement.yml +++ b/tasks/configure_replication_agreement.yml @@ -6,11 +6,11 @@ - name: Determine if outbound replication should be enabled initially ansible.builtin.set_fact: - dirsrv_outbound_replication_enabled: "{{ - 'on' if - (dirsrv_begin_replication_immediately and not dirsrv_initial_sync_source) or - (not dirsrv_initial_sync_source) - else 'off' + dirsrv_outbound_replication_enabled: "{{ + 'on' if + (dirsrv_begin_replication_immediately and not dirsrv_initial_sync_source) or + (not dirsrv_initial_sync_source) + else 'off' }}" - name: Set agreement name diff --git a/tasks/enable_outbound_replication.yml b/tasks/enable_outbound_replication.yml index 714bae6..feda39a 100644 --- a/tasks/enable_outbound_replication.yml +++ b/tasks/enable_outbound_replication.yml @@ -12,12 +12,12 @@ - name: Enable outbound replication after sync ansible.builtin.shell: > - ldapmodify + ldapmodify -x {{ '-ZZ' if dirsrv_use_starttls else '' }} - -H {{ dirsrv_server_uri }} - -D "{{ dirsrv_rootdn }}" - -w "{{ dirsrv_rootdn_password }}" + -H {{ dirsrv_server_uri }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" -f /tmp/dirsrv_enable_replication.ldif register: enable_result failed_when: enable_result.rc != 0 diff --git a/tasks/main.yml b/tasks/main.yml index 3157d32..83ce5ed 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -48,6 +48,7 @@ - name: Restart service after replication agreement creation ansible.builtin.include_tasks: restart_service.yml - when: - - dirsrv_instance_is_supplier or dirsrv_instance_is_consumer - - dirsrv_initial_sync_source | length > 0 and dirsrv_enable_outbound_replication_after_sync + when: + - dirsrv_instance_is_supplier or dirsrv_instance_is_consumer + - dirsrv_initial_sync_source | length > 0 and dirsrv_enable_outbound_replication_after_sync + diff --git a/tasks/perform_initial_sync.yml b/tasks/perform_initial_sync.yml index 45ac83c..da7245c 100644 --- a/tasks/perform_initial_sync.yml +++ b/tasks/perform_initial_sync.yml @@ -17,18 +17,18 @@ - name: Trigger initial sync from source server ansible.builtin.shell: > - ldapmodify - -x + ldapmodify + -x {{ '-ZZ' if dirsrv_use_starttls else '' }} - -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} - -D "{{ dirsrv_rootdn }}" - -w "{{ dirsrv_rootdn_password }}" + -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" -f /tmp/dirsrv_initial_sync.ldif delegate_to: "{{ dirsrv_initial_sync_source }}" register: sync_result failed_when: sync_result.rc != 0 and "Replica is being initialized" not in sync_result.stderr when: dirsrv_perform_initial_sync - + - name: Remove temporary LDIF file ansible.builtin.file: path: "/tmp/dirsrv_initial_sync.ldif" @@ -38,12 +38,12 @@ - name: Wait for initial sync to complete ansible.builtin.shell: > - ldapsearch + ldapsearch -x {{ '-ZZ' if dirsrv_use_starttls else '' }} - -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} - -D "{{ dirsrv_rootdn }}" - -w "{{ dirsrv_rootdn_password }}" + -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} + -D "{{ dirsrv_rootdn }}" + -w "{{ dirsrv_rootdn_password }}" -b "cn=agreement_with_{{ ansible_hostname + "_" + ansible_domain | replace('.', '_') }},cn=replica,cn={{ dirsrv_suffix | replace('=','\=') | replace(',','\,') }},cn=mapping tree,cn=config" nsds5replicaUpdateInProgress | grep "nsds5replicaUpdateInProgress:" | awk '{print $2}' register: sync_status diff --git a/tasks/restart_service.yml b/tasks/restart_service.yml index b296724..f38d20f 100644 --- a/tasks/restart_service.yml +++ b/tasks/restart_service.yml @@ -1,3 +1,4 @@ +--- - name: restart local 389ds ansible.builtin.service: name: dirsrv@{{ dirsrv_serverid }} @@ -7,4 +8,4 @@ ansible.builtin.service: name: dirsrv@{{ dirsrv_serverid }} state: restarted - delegate_to: "{{ dirsrv_consumer_uri | urlsplit('hostname') }}" \ No newline at end of file + delegate_to: "{{ dirsrv_consumer_uri | urlsplit('hostname') }}" From 602acc8ae1fc8f5409e7523951fbdb170a50a48f Mon Sep 17 00:00:00 2001 From: Anders Andersson Date: Thu, 9 Apr 2026 14:46:02 +0200 Subject: [PATCH 3/4] last lint --- tasks/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/tasks/main.yml b/tasks/main.yml index 83ce5ed..557271d 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -51,4 +51,3 @@ when: - dirsrv_instance_is_supplier or dirsrv_instance_is_consumer - dirsrv_initial_sync_source | length > 0 and dirsrv_enable_outbound_replication_after_sync - From 8b74ac27d37e37afc95d001bfc273cbcbaaacab3 Mon Sep 17 00:00:00 2001 From: Anders Andersson Date: Thu, 9 Apr 2026 16:17:34 +0200 Subject: [PATCH 4/4] Refactor: Change shell to command module for consistency and improve error handling in replication tasks --- tasks/enable_outbound_replication.yml | 3 ++- tasks/perform_initial_sync.yml | 14 ++++++++------ tasks/restart_service.yml | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tasks/enable_outbound_replication.yml b/tasks/enable_outbound_replication.yml index feda39a..a0b6b40 100644 --- a/tasks/enable_outbound_replication.yml +++ b/tasks/enable_outbound_replication.yml @@ -11,7 +11,7 @@ when: dirsrv_perform_initial_sync and dirsrv_enable_outbound_replication_after_sync - name: Enable outbound replication after sync - ansible.builtin.shell: > + ansible.builtin.command: > ldapmodify -x {{ '-ZZ' if dirsrv_use_starttls else '' }} @@ -20,6 +20,7 @@ -w "{{ dirsrv_rootdn_password }}" -f /tmp/dirsrv_enable_replication.ldif register: enable_result + changed_when: enable_result.rc == 0 failed_when: enable_result.rc != 0 when: dirsrv_perform_initial_sync and dirsrv_enable_outbound_replication_after_sync diff --git a/tasks/perform_initial_sync.yml b/tasks/perform_initial_sync.yml index da7245c..c8c78a5 100644 --- a/tasks/perform_initial_sync.yml +++ b/tasks/perform_initial_sync.yml @@ -16,7 +16,7 @@ when: dirsrv_perform_initial_sync - name: Trigger initial sync from source server - ansible.builtin.shell: > + ansible.builtin.command: > ldapmodify -x {{ '-ZZ' if dirsrv_use_starttls else '' }} @@ -26,7 +26,8 @@ -f /tmp/dirsrv_initial_sync.ldif delegate_to: "{{ dirsrv_initial_sync_source }}" register: sync_result - failed_when: sync_result.rc != 0 and "Replica is being initialized" not in sync_result.stderr + changed_when: sync_result.rc == 0 and "Replica is being initialized" not in (sync_result.stderr | default('')) + failed_when: sync_result.rc != 0 and "Replica is being initialized" not in (sync_result.stderr | default('')) when: dirsrv_perform_initial_sync - name: Remove temporary LDIF file @@ -37,17 +38,18 @@ when: dirsrv_perform_initial_sync - name: Wait for initial sync to complete - ansible.builtin.shell: > + ansible.builtin.command: > ldapsearch -x {{ '-ZZ' if dirsrv_use_starttls else '' }} -H {{ dirsrv_server_uri | regex_replace(ansible_fqdn, dirsrv_initial_sync_source) }} -D "{{ dirsrv_rootdn }}" -w "{{ dirsrv_rootdn_password }}" - -b "cn=agreement_with_{{ ansible_hostname + "_" + ansible_domain | replace('.', '_') }},cn=replica,cn={{ dirsrv_suffix | replace('=','\=') | replace(',','\,') }},cn=mapping tree,cn=config" - nsds5replicaUpdateInProgress | grep "nsds5replicaUpdateInProgress:" | awk '{print $2}' + -b "cn=agreement_with_{{ ansible_hostname + "_" + ansible_domain | replace('.', '_') }},cn=replica,cn={{ dirsrv_suffix | replace('=', '\=') | replace(',', '\,') }},cn=mapping tree,cn=config" + nsds5replicaUpdateInProgress register: sync_status - until: sync_status.stdout.strip() == "FALSE" + changed_when: false + until: "'nsds5replicaUpdateInProgress: FALSE' in (sync_status.stdout | default(''))" retries: "{{ (dirsrv_initial_sync_timeout / 10) | int }}" delay: 10 delegate_to: "{{ dirsrv_initial_sync_source }}" diff --git a/tasks/restart_service.yml b/tasks/restart_service.yml index f38d20f..747dd15 100644 --- a/tasks/restart_service.yml +++ b/tasks/restart_service.yml @@ -1,10 +1,10 @@ --- -- name: restart local 389ds +- name: Restart local 389ds ansible.builtin.service: name: dirsrv@{{ dirsrv_serverid }} state: restarted -- name: restart remote consumer 389ds +- name: Restart remote consumer 389ds ansible.builtin.service: name: dirsrv@{{ dirsrv_serverid }} state: restarted