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..94c9d2a 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 diff --git a/tasks/configure_replication_agreement.yml b/tasks/configure_replication_agreement.yml index ac65b97..41b413a 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..a0b6b40 --- /dev/null +++ b/tasks/enable_outbound_replication.yml @@ -0,0 +1,31 @@ +--- +- 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.command: > + 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 + changed_when: enable_result.rc == 0 + 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..557271d 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..c8c78a5 --- /dev/null +++ b/tasks/perform_initial_sync.yml @@ -0,0 +1,56 @@ +--- +- 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.command: > + 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 + 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 + 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.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 + register: sync_status + 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 }}" + 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..747dd15 --- /dev/null +++ b/tasks/restart_service.yml @@ -0,0 +1,11 @@ +--- +- 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') }}"