From e1ebb71d06185ff96e759b2011b22e093dab57cb Mon Sep 17 00:00:00 2001
From: Mark Reynolds <mreynolds@redhat.com>
Date: Mon, 1 May 2017 18:48:11 -0400
Subject: [PATCH] Ticket 49239 - Add a tool to compare entries on LDAP servers.

Description:  This tool writes a report on replication comparisons
              between two replicas:

              http://www.port389.org/docs/389ds/design/repl-diff-tool-design.html

https://pagure.io/389-ds-base/issue/49239

Reviewed by: ?
---
 Makefile.am                         |    2 +-
 ldap/admin/src/scripts/repl-diff.py | 1110 +++++++++++++++++++++++++++++++++++
 man/man8/repl-diff.py.8             |   85 +++
 3 files changed, 1196 insertions(+), 1 deletion(-)
 create mode 100644 ldap/admin/src/scripts/repl-diff.py
 create mode 100644 man/man8/repl-diff.py.8

diff --git a/Makefile.am b/Makefile.am
index e2421ab..2a98d91 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -882,7 +882,7 @@ dist_man_MANS = man/man1/dbscan.1 \
         man/man8/db2index.8 man/man8/db2index.pl.8 \
         man/man8/ldif2db.8 man/man8/ldif2db.pl.8 \
         man/man8/dbverify.8 man/man8/verify-db.pl.8 \
-        man/man8/dbmon.sh.8 \
+        man/man8/dbmon.sh.8 man/man8/repl-diff.py.8 \
         man/man8/dn2rdn.8 man/man8/ldif2ldap.8 \
         man/man8/restoreconfig.8 man/man8/saveconfig.8 \
         man/man8/suffix2instance.8 man/man8/monitor.8 \
diff --git a/ldap/admin/src/scripts/repl-diff.py b/ldap/admin/src/scripts/repl-diff.py
new file mode 100644
index 0000000..78f623c
--- /dev/null
+++ b/ldap/admin/src/scripts/repl-diff.py
@@ -0,0 +1,1110 @@
+# --- BEGIN COPYRIGHT BLOCK ---
+# Copyright (C) 2017 Red Hat, Inc.
+# All rights reserved.
+#
+# License: GPL (version 3 or any later version).
+# See LICENSE for details.
+# --- END COPYRIGHT BLOCK ---
+#
+import re
+import time
+import ldap
+import ldapurl
+import argparse
+
+from ldap.ldapobject import SimpleLDAPObject
+from ldap.cidict import cidict
+from ldap.controls import SimplePagedResultsControl
+
+VERSION = "1.2"
+RUV_FILTER = '(&(nsuniqueid=ffffffff-ffffffff-ffffffff-ffffffff)(objectclass=nstombstone))'
+LDAP = 'ldap'
+LDAPS = 'ldaps'
+LDAPI = 'ldapi'
+VALID_PROTOCOLS = [LDAP, LDAPS, LDAPI]
+vucsn_pattern = re.compile(';vucsn-([A-Fa-f0-9]+)')
+vdcsn_pattern = re.compile(';vdcsn-([A-Fa-f0-9]+)')
+mdcsn_pattern = re.compile(';mdcsn-([A-Fa-f0-9]+)')
+adcsn_pattern = re.compile(';adcsn-([A-Fa-f0-9]+)')
+
+
+class Entry(object):
+    ''' This is a stripped down version of Entry from python-lib389.
+    Once python-lib389 is released on RHEL this class will go away.
+    '''
+    def __init__(self, entrydata):
+        if entrydata:
+            self.dn = entrydata[0]
+            self.data = cidict(entrydata[1])
+
+    def __getitem__(self, name):
+        return self.__getattr__(name)
+
+    def __getattr__(self, name):
+        if name == 'dn' or name == 'data':
+            return self.__dict__.get(name, None)
+        return self.getValue(name)
+
+
+def get_entry(entries, dn):
+    ''' Loop over enties looking for a matching dn
+    '''
+    for entry in entries:
+        if entry.dn == dn:
+            return entry
+    return None
+
+
+def remove_entry(rentries, dn):
+    ''' Remove an entry from the array of entries
+    '''
+    for entry in rentries:
+        if entry.dn == dn:
+            rentries.remove(entry)
+            break
+
+
+def extract_time(stateinfo):
+    ''' Take the nscpEntryWSI attribute and get the most recent timestamp from
+    one of the csns (vucsn, vdcsn, mdcsn, adcsn)
+
+    Return the timestamp in decimal
+    '''
+    timestamp = 0
+    for pattern in [vucsn_pattern, vdcsn_pattern, mdcsn_pattern, adcsn_pattern]:
+        csntime = pattern.search(stateinfo)
+        if csntime:
+            hextime = csntime.group(1)[:8]
+            dectime = int(hextime, 16)
+            if dectime > timestamp:
+                timestamp = dectime
+
+    return timestamp
+
+
+def convert_timestamp(timestamp):
+    ''' Convert createtimestamp to ctime: 20170405184656Z -> Wed Apr  5 19:46:56 2017
+    '''
+    time_tuple = (int(timestamp[:4]), int(timestamp[4:6]), int(timestamp[6:8]),
+                  int(timestamp[8:10]), int(timestamp[10:12]), int(timestamp[12:14]),
+                  0, 0, 0)
+    secs = time.mktime(time_tuple)
+    return time.ctime(secs)
+
+
+def convert_entries(entries):
+    '''Convert and normalize the ldap entries.  Take note of conflicts and tombstones
+    '''
+    new_entries = []
+    conflict_entries = []
+    result = {}
+    tombstones = 0
+    for entry in entries:
+        new_entry = Entry(entry)
+        new_entry.data = {k.lower(): v for k, v in list(new_entry.data.items())}
+        if 'nsds5replconflict' in new_entry.data:
+            conflict_entries.append(new_entry)
+        else:
+            new_entries.append(new_entry)
+
+        if 'nstombstonecsn' in new_entry.data:
+            tombstones += 1
+    del entries
+
+    result['entries'] = new_entries
+    result['conflicts'] = conflict_entries
+    result['tombstones'] = tombstones
+
+    return result
+
+
+def report_conflict(entry, attr, opts):
+    ''' Check the createtimestamp/modifytimestamp (which ever is larger),
+    and make sure its past the ignore time.
+
+    return True - if the conflict should be reported
+    return False - if it should be ignored
+    '''
+    if opts['lag'] == 0:
+        return True
+
+    report = True
+
+    if 'nscpentrywsi' in entry.data:
+        found = False
+        for val in entry.data['nscpentrywsi']:
+            if val.lower().startswith(attr + ';'):
+                if (opts['starttime'] - extract_time(val)) <= opts['lag']:
+                    report = False
+
+    return report
+
+
+def format_diff(diff):
+    ''' Take the diff map and format it for friendly output
+    '''
+    diff_report = "%s\n" % (diff['dn'])
+    diff_report += ("-" * len(diff['dn'])) + "\n"
+    for missing in diff['missing']:
+        diff_report += "%s\n" % (missing)
+    for val_diff in diff['diff']:
+        diff_report += "%s\n" % (val_diff)
+
+    return diff_report
+
+
+def get_ruv_report(opts):
+    '''Print a friendly RUV report
+    '''
+    opts['master_ruv'].sort()
+    opts['replica_ruv'].sort()
+
+    report = "Master RUV:\n"
+    for element in opts['master_ruv']:
+        report += "  %s\n" % (element)
+    report += "\nReplica RUV:\n"
+    for element in opts['replica_ruv']:
+        report += "  %s\n" % (element)
+    report += "\n\n"
+
+    return report
+
+
+#
+# Offline mode helper functions
+#
+def ldif_search(LDIF, dn, conflicts=False):
+    ''' Search ldif by DN
+    '''
+    result = {}
+    data = {}
+    found_conflict = False
+    found_part_dn = False
+    found = False
+    reset_line = False
+    count = 0
+
+    result['entry'] = None
+    result['conflict'] = None
+    result['tombstone'] = False
+
+    for line in LDIF:
+        count += 1
+        line = line.rstrip()
+        if reset_line:
+            reset_line = False
+            line = prev_line
+        if found:
+            if line == "":
+                # End of entry
+                break
+
+            if line[0] == ' ':
+                # continuation line
+                prev = data[attr][len(data[attr]) - 1]
+                data[attr][len(data[attr]) - 1] = prev + line.strip()
+                continue
+
+            value_set = line.split(":", 1)
+            attr = value_set[0].lower()
+            if attr.startswith('nsds5replconflict'):
+                found_conflict = True
+            if attr.startswith('nstombstonecsn'):
+                result['tombstone'] = True
+
+            if attr in data:
+                data[attr].append(value_set[1].strip())
+            else:
+                data[attr] = [value_set[1].strip()]
+        elif found_part_dn:
+            if line[0] == ' ':
+                part_dn += line[1:].lower()
+            else:
+                # We have the full dn
+                found_part_dn = False
+                reset_line = True
+                prev_line = line
+                if part_dn == dn:
+                    found = True
+                    continue
+        if line.startswith('dn: '):
+            if line[4:].lower() == dn:
+                found = True
+                continue
+            else:
+                part_dn = line[4:].lower()
+                found_part_dn = True
+
+    result['idx'] = count
+    if found_conflict:
+        result['entry'] = None
+        result['conflict'] = Entry([dn, data])
+    elif found:
+        result['conflict'] = None
+        result['entry'] = Entry([dn, data])
+
+    return result
+
+
+def get_dns(LDIF, opts):
+    ''' Get all the DN's
+    '''
+    dns = []
+    found = False
+    for line in LDIF:
+        if line.startswith('dn: ') and line[4:].startswith('nsuniqueid=ffffffff-ffffffff-ffffffff-ffffffff'):
+            opts['ruv_dn'] = line[4:].lower().strip()
+        elif line.startswith('dn: '):
+            found = True
+            dn = line[4:].lower().strip()
+            continue
+
+        if found and line[0] == ' ':
+            # continuation line
+            dn += line.lower().strip()
+        elif found and line[0] != ' ':
+            # end of DN - add it to the list
+            found = False
+            dns.append(dn)
+
+    return dns
+
+
+def get_ldif_ruv(LDIF, opts):
+    ''' Search the ldif and get the ruv entry
+    '''
+    LDIF.seek(0)
+    result = ldif_search(LDIF, opts['ruv_dn'])
+    return result['entry'].data['nsds50ruv']
+
+
+def cmp_entry(mentry, rentry, opts):
+    ''' Compare the two entries, and return a diff map
+    '''
+    diff = {}
+    diff['dn'] = mentry['dn']
+    diff['missing'] = []
+    diff['diff'] = []
+    diff_count = 0
+
+    rlist = list(rentry.data.keys())
+    mlist = list(mentry.data.keys())
+
+    #
+    # Check master
+    #
+    for mattr in mlist:
+        if mattr in opts['ignore']:
+            continue
+
+        if mattr not in rlist:
+            # Replica is missing the attribute.  Display the state info
+            if report_conflict(mentry, mattr, opts):
+                diff['missing'].append(" - Replica missing attribute: \"%s\"" % (mattr))
+                diff_count += 1
+                if 'nscpentrywsi' in mentry.data:
+                    found = False
+                    for val in mentry.data['nscpentrywsi']:
+                        if val.lower().startswith(mattr + ';'):
+                            if not found:
+                                diff['missing'].append("")
+                            found = True
+                            diff['missing'].append(" - Master's State Info: %s" % (val))
+                            diff['missing'].append(" - Date: %s\n" % (time.ctime(extract_time(val))))
+                else:
+                    diff['missing'].append("")
+
+        elif mentry.data[mattr] != rentry.data[mattr]:
+            # Replica's attr value is different
+            if report_conflict(rentry, mattr, opts) and report_conflict(mentry, mattr, opts):
+                diff['diff'].append(" - Attribute '%s' is different:" % mattr)
+                if 'nscpentrywsi' in mentry.data:
+                    # Process Master
+                    found = False
+                    for val in mentry.data['nscpentrywsi']:
+                        if val.lower().startswith(mattr + ';'):
+                            if not found:
+                                diff['diff'].append("      Master:")
+                            diff['diff'].append("        - State Info: %s" % (val))
+                            diff['diff'].append("        - Date:       %s\n" % (time.ctime(extract_time(val))))
+                            found = True
+                    if not found:
+                        diff['diff'].append("      Master: ")
+                        for val in mentry.data[mattr]:
+                            diff['diff'].append("        - Origin value: %s" % (val))
+                        diff['diff'].append("")
+
+                    # Process Replica
+                    found = False
+                    for val in rentry.data['nscpentrywsi']:
+                        if val.lower().startswith(mattr + ';'):
+                            if not found:
+                                diff['diff'].append("      Replica:")
+                            diff['diff'].append("        - State Info: %s" % (val))
+                            diff['diff'].append("        - Date:       %s\n" % (time.ctime(extract_time(val))))
+                            found = True
+                    if not found:
+                        diff['diff'].append("      Replica: ")
+                        for val in rentry.data[mattr]:
+                            diff['diff'].append("        - Origin value: %s" % (val))
+                        diff['diff'].append("")
+                else:
+                    # no state info
+                    diff['diff'].append("      Master: ")
+                    for val in mentry.data[mattr]:
+                        diff['diff'].append("        - %s: %s" % (mattr, val))
+                    diff['diff'].append("      Replica: ")
+                    for val in rentry.data[mattr]:
+                        diff['diff'].append("        - %s: %s\n" % (mattr, val))
+
+                diff_count += 1
+
+    #
+    # Check replica (only need to check for missing attributes)
+    #
+    for rattr in rlist:
+        if rattr in opts['ignore']:
+            continue
+
+        if rattr not in mlist:
+            # Master is missing the attribute
+            if report_conflict(rentry, rattr, opts):
+                diff['missing'].append(" - Master missing attribute: \"%s\"" % (rattr))
+                diff_count += 1
+                if 'nscpentrywsi' in rentry.data:
+                    found = False
+                    for val in rentry.data['nscpentrywsi']:
+                        if val.lower().startswith(rattr + ';'):
+                            if not found:
+                                diff['missing'].append("")
+                            found = True
+                            diff['missing'].append(" - Replica's State Info: %s" % (val))
+                            diff['missing'].append(" - Date: %s\n" % (time.ctime(extract_time(val))))
+                else:
+                    # No state info
+                    diff['missing'].append("")
+
+    if diff_count > 0:
+        diff['count'] = str(diff_count)
+        return diff
+    else:
+        return None
+
+
+def do_offline_report(opts, output_file=None):
+    ''' Check for inconsistencies between two ldifs
+    '''
+    missing_report = ""
+    diff_report = []
+    final_report = ""
+    mconflicts = []
+    rconflicts = []
+    rtombstones = 0
+    mtombstones = 0
+
+    # Open LDIF files
+    try:
+        MLDIF = open(opts['mldif'], "r")
+    except Exception as e:
+        print('Failed to open Master LDIF: ' + str(e))
+        return None
+
+    try:
+        RLDIF = open(opts['rldif'], "r")
+    except Exception as e:
+        print('Failed to open Replica LDIF: ' + str(e))
+        return None
+
+    # Get all the dn's, and entry counts
+    print ("Gathering all the DN's...")
+    master_dns = get_dns(MLDIF, opts)
+    replica_dns = get_dns(RLDIF, opts)
+    m_count = len(master_dns)
+    r_count = len(replica_dns)
+
+    # Get DB RUV
+    print ("Gathering the database RUV's...")
+    opts['master_ruv'] = get_ldif_ruv(MLDIF, opts)
+    opts['replica_ruv'] = get_ldif_ruv(RLDIF, opts)
+
+    # Reset the cursors
+    idx = 0
+    MLDIF.seek(idx)
+    RLDIF.seek(idx)
+
+    # Compare the master entries with the replica's
+    print ("Comparing Master to Replica...")
+    missing = False
+    for dn in master_dns:
+        mresult = ldif_search(MLDIF, dn, True)
+        rresult = ldif_search(RLDIF, dn, True)
+
+        if mresult['tombstone']:
+            mtombstones += 1
+
+        if mresult['conflict'] is not None or rresult['conflict'] is not None:
+            if mresult['conflict'] is not None:
+                mconflicts.append(mresult['conflict'])
+        elif rresult['entry'] is None:
+            # missing entry - restart the search from beginning
+            RLDIF.seek(0)
+            rresult = ldif_search(RLDIF, dn)
+            if rresult['entry'] is None:
+                # missing entry in rentries
+                RLDIF.seek(mresult['idx'])  # Set the cursor to the last good line
+                if not missing:
+                    missing_report += ('Replica is missing entries:\n')
+                    missing = True
+                if mresult['entry'] and 'createtimestamp' in mresult['entry'].data:
+                    missing_report += ('  - %s  (Master\'s creation date:  %s)\n' %
+                                       (dn, convert_timestamp(mresult['entry'].data['createtimestamp'][0])))
+                else:
+                    missing_report += ('  - %s\n' % dn)
+            else:
+                # Compare the entries
+                diff = cmp_entry(mresult['entry'], rresult['entry'], opts)
+                if diff:
+                    diff_report.append(format_diff(diff))
+        else:
+            # Compare the entries
+            diff = cmp_entry(mresult['entry'], rresult['entry'], opts)
+            if diff:
+                # We have a diff, report the result
+                diff_report.append(format_diff(diff))
+    if missing:
+        missing_report += ('\n')
+
+    # Search Replica, and look for missing entries only.  Count entries as well
+    print ("Comparing Replica to Master...")
+    MLDIF.seek(0)
+    RLDIF.seek(0)
+    missing = False
+    for dn in replica_dns:
+        rresult = ldif_search(RLDIF, dn)
+        mresult = ldif_search(MLDIF, dn)
+
+        if rresult['tombstone']:
+            rtombstones += 1
+        if mresult['entry'] is not None or rresult['conflict'] is not None:
+            if rresult['conflict'] is not None:
+                rconflicts.append(rresult['conflict'])
+        elif mresult['entry'] is None:
+            # missing entry
+            MLDIF.seek(0)
+            mresult = ldif_search(MLDIF, dn)
+            if mresult['entry'] is None and mresult['conflict'] is not None:
+                MLDIF.seek(rresult['idx'])  # Set the cursor to the last good line
+                if not missing:
+                    missing_report += ('Master is missing entries:\n')
+                    missing = True
+                if 'createtimestamp' in rresult['entry'].data:
+                    missing_report += ('  - %s  (Replica\'s creation date:  %s)\n' %
+                                       (dn, convert_timestamp(rresult['entry'].data['createtimestamp'][0])))
+                else:
+                    missing_report += ('  - %s\n')
+    if missing:
+        missing_report += ('\n')
+
+    MLDIF.close()
+    RLDIF.close()
+
+    print ("Preparing report...")
+
+    # Build final report
+    final_report = ('=' * 80 + '\n')
+    final_report += ('         Replication Synchronization Report  (%s)\n' %
+                     time.ctime())
+    final_report += ('=' * 80 + '\n\n\n')
+    final_report += ('Database RUV\'s\n')
+    final_report += ('=====================================================\n\n')
+    final_report += get_ruv_report(opts)
+    final_report += ('Entry Counts\n')
+    final_report += ('=====================================================\n\n')
+    final_report += ('Master:  %d\n' % (m_count))
+    final_report += ('Replica: %d\n\n' % (r_count))
+
+    final_report += ('\nTombstones\n')
+    final_report += ('=====================================================\n\n')
+    final_report += ('Master:  %d\n' % (mtombstones))
+    final_report += ('Replica: %d\n' % (rtombstones))
+
+    final_report += get_conflict_report(mconflicts, rconflicts, opts['conflicts'], format_conflicts=True)
+    if missing_report != "":
+        final_report += ('\nMissing Entries\n')
+        final_report += ('=====================================================\n\n')
+        final_report += ('%s\n' % (missing_report))
+    if len(diff_report) > 0:
+        final_report += ('\nEntry Inconsistencies\n')
+        final_report += ('=====================================================\n\n')
+    for diff in diff_report:
+        final_report += ('%s\n' % (diff))
+    if missing_report == "" and len(diff_report) == 0 and m_count == r_count:
+        final_report += ('\nResult\n')
+        final_report += ('=====================================================\n\n')
+        final_report += ('No differences between Master and Replica\n')
+
+    if output_file:
+        output_file.write(final_report)
+    else:
+        print final_report
+
+
+def check_for_diffs(mentries, rentries, report, opts):
+    ''' Check for diffs, return the updated report
+    '''
+    diff_report = []
+    m_missing = []
+    r_missing = []
+
+    # Add the stragglers
+    if len(report['r_missing']) > 0:
+        mentries += report['r_missing']
+    if len(report['m_missing']) > 0:
+        rentries += report['m_missing']
+
+    for mentry in mentries:
+        rentry = get_entry(rentries, mentry.dn)
+        if rentry:
+            diff = cmp_entry(mentry, rentry, opts)
+            if diff:
+                diff_report.append(format_diff(diff))
+            # Now remove the rentry from the rentries so we can find stragglers
+            remove_entry(rentries, rentry.dn)
+        else:
+            # Add missing entry in Replica
+            r_missing.append(mentry)
+
+    for rentry in rentries:
+        # We should not have any entries if we are sync
+        m_missing.append(rentry)
+
+    if len(diff_report) > 0:
+        report['diff'] += diff_report
+
+    # Reset the missing entries
+    report['m_missing'] = m_missing
+    report['r_missing'] = r_missing
+
+    return report
+
+
+def connect_to_replicas(opts):
+    ''' Start the paged results searches
+    '''
+    print('Connecting to servers...')
+
+    if opts['mprotocol'].lower() == 'ldapi':
+        muri = "%s://%s" % (opts['mprotocol'], opts['mhost'].replace("/", "%2f"))
+    else:
+        muri = "%s://%s:%s/" % (opts['mprotocol'], opts['mhost'], opts['mport'])
+    master = SimpleLDAPObject(muri)
+
+    if opts['rprotocol'].lower() == 'ldapi':
+        ruri = "%s://%s" % (opts['rprotocol'], opts['rhost'].replace("/", "%2f"))
+    else:
+        ruri = "%s://%s:%s/" % (opts['rprotocol'], opts['rhost'], opts['rport'])
+    replica = SimpleLDAPObject(ruri)
+
+    # Setup Secure Conenction
+    if opts['certdir'] is not None:
+        # Setup Master
+        if opts['mprotocol'] != LDAPI:
+            master.set_option(ldap.OPT_X_TLS_CACERTDIR, opts['certdir'])
+            master.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_HARD)
+            if opts['mprotocol'] == LDAP:
+                # Do StartTLS
+                try:
+                    master.start_tls_s()
+                except ldap.LDAPError as e:
+                    print('TLS negotiation failed on Master: %s' % str(e))
+                    exit(1)
+
+        # Setup Replica
+        if opts['rprotocol'] != LDAPI:
+            replica.set_option(ldap.OPT_X_TLS_CACERTDIR, opts['certdir'])
+            replica.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_HARD)
+            if opts['mprotocol'] == LDAP:
+                # Do StartTLS
+                try:
+                    replica.start_tls_s()
+                except ldap.LDAPError as e:
+                    print('TLS negotiation failed on Master: %s' % str(e))
+                    exit(1)
+
+    # Open connection to master
+    try:
+        master.simple_bind_s(opts['binddn'], opts['bindpw'])
+    except ldap.SERVER_DOWN as e:
+        print("Cannot connect to %r" % muri)
+        exit(1)
+    except ldap.LDAPError as e:
+        print("Error: Failed to authenticate to Master: %s", str(e))
+        exit(1)
+
+    # Open connection to replica
+    try:
+        replica.simple_bind_s(opts['binddn'], opts['bindpw'])
+    except ldap.SERVER_DOWN as e:
+        print("Cannot connect to %r" % ruri)
+        exit(1)
+    except ldap.LDAPError as e:
+        print("Error: Failed to authenticate to Replica: %s", str(e))
+        exit(1)
+
+    # Get the RUVs
+    print ("Gathering Master's RUV...")
+    try:
+        master_ruv = master.search_s(opts['suffix'], ldap.SCOPE_SUBTREE, RUV_FILTER, ['nsds50ruv'])
+        if len(master_ruv) > 0:
+            opts['master_ruv'] = master_ruv[0][1]['nsds50ruv']
+        else:
+            print("Error: Master does not have an RUV entry")
+            exit(1)
+    except ldap.LDAPError as e:
+        print("Error: Failed to get Master RUV entry: %s", str(e))
+        exit(1)
+
+    print ("Gathering Replica's RUV...")
+    try:
+        replica_ruv = replica.search_s(opts['suffix'], ldap.SCOPE_SUBTREE, RUV_FILTER, ['nsds50ruv'])
+        if len(replica_ruv) > 0:
+            opts['replica_ruv'] = replica_ruv[0][1]['nsds50ruv']
+        else:
+            print("Error: Replica does not have an RUV entry")
+            exit(1)
+
+    except ldap.LDAPError as e:
+        print("Error: Failed to get Replica RUV entry: %s", str(e))
+        exit(1)
+
+    return (master, replica, opts)
+
+
+def print_online_report(report, opts, output_file):
+    ''' Print the online report
+    '''
+    print ('Preparing final report...')
+    m_missing = len(report['m_missing'])
+    r_missing = len(report['r_missing'])
+    final_report = ('=' * 80 + '\n')
+    final_report += ('         Replication Synchronization Report  (%s)\n' %
+                     time.ctime())
+    final_report += ('=' * 80 + '\n\n\n')
+    final_report += ('Database RUV\'s\n')
+    final_report += ('=====================================================\n\n')
+    final_report += get_ruv_report(opts)
+    final_report += ('Entry Counts\n')
+    final_report += ('=====================================================\n\n')
+    final_report += ('Master:  %d\n' % (report['m_count']))
+    final_report += ('Replica: %d\n\n' % (report['r_count']))
+    final_report += ('\nTombstones\n')
+    final_report += ('=====================================================\n\n')
+    final_report += ('Master:  %d\n' % (report['mtombstones']))
+    final_report += ('Replica: %d\n' % (report['rtombstones']))
+    final_report += report['conflict']
+    missing = False
+    if r_missing > 0 or m_missing > 0:
+        missing = True
+        final_report += ('\nMissing Entries\n')
+        final_report += ('=====================================================\n\n')
+        if m_missing > 0:
+            final_report += ('  Entries missing on Master:\n')
+            for entry in report['m_missing']:
+                if 'createtimestamp' in entry.data:
+                    final_report += ('   - %s  (Created on Replica at: %s)\n' %
+                                     (entry.dn, convert_timestamp(entry.data['createtimestamp'][0])))
+                else:
+                    final_report += ('   - %s\n' % (entry.dn))
+
+        if r_missing > 0:
+            if m_missing > 0:
+                final_report += ('\n')
+            final_report += ('  Entries missing on Replica:\n')
+            for entry in report['r_missing']:
+                if 'createtimestamp' in entry.data:
+                    final_report += ('   - %s  (Created on Master at: %s)\n' %
+                                     (entry.dn, convert_timestamp(entry.data['createtimestamp'][0])))
+                else:
+                    final_report += ('   - %s\n' % (entry.dn))
+
+    if len(report['diff']) > 0:
+        final_report += ('\n\nEntry Inconsistencies\n')
+        final_report += ('=====================================================\n\n')
+        for diff in report['diff']:
+            final_report += ('%s\n' % (diff))
+
+    if not missing and len(report['diff']) == 0 and report['m_count'] == report['r_count']:
+        final_report += ('\nResult\n')
+        final_report += ('=====================================================\n\n')
+        final_report += ('No differences between Master and Replica\n')
+
+    if output_file:
+        output_file.write(final_report)
+    else:
+        print final_report
+
+
+def remove_state_info(entry):
+    ''' Remove the state info for the attributes used in the conflict report
+    '''
+    attrs = ['objectclass', 'nsds5replconflict', 'createtimestamp']
+    for key, val in list(entry.data.items()):
+        for attr in attrs:
+            if key.lower().startswith(attr):
+                entry.data[attr] = entry.data[key]
+                del entry.data[key]
+
+
+def get_conflict_report(mentries, rentries, verbose, format_conflicts=False):
+    ''' Gather the conflict entry dn's for each replica
+    '''
+    m_conflicts = []
+    r_conflicts = []
+
+    for entry in mentries:
+        if format_conflicts:
+            remove_state_info(entry)
+
+        if 'glue' in entry.data['objectclass']:
+            m_conflicts.append({'dn': entry.dn, 'conflict': entry.data['nsds5replconflict'][0],
+                                'date': entry.data['createtimestamp'][0], 'glue': 'yes'})
+        else:
+            m_conflicts.append({'dn': entry.dn, 'conflict': entry.data['nsds5replconflict'][0],
+                                'date': entry.data['createtimestamp'][0], 'glue': 'no'})
+    for entry in rentries:
+        if format_conflicts:
+            remove_state_info(entry)
+
+        if 'glue' in entry.data['objectclass']:
+            r_conflicts.append({'dn': entry.dn, 'conflict': entry.data['nsds5replconflict'][0],
+                                'date': entry.data['createtimestamp'][0], 'glue': 'yes'})
+        else:
+            r_conflicts.append({'dn': entry.dn, 'conflict': entry.data['nsds5replconflict'][0],
+                                'date': entry.data['createtimestamp'][0], 'glue': 'no'})
+
+    if len(m_conflicts) > 0 or len(r_conflicts) > 0:
+        report = "\n\nConflict Entries\n"
+        report += "=====================================================\n\n"
+        if len(m_conflicts) > 0:
+            report += ('Master Conflict Entries: %d\n' % (len(m_conflicts)))
+            if verbose:
+                for entry in m_conflicts:
+                    report += ('\n - %s\n' % (entry['dn']))
+                    report += ('    - Conflict:   %s\n' % (entry['conflict']))
+                    report += ('    - Glue entry: %s\n' % (entry['glue']))
+                    report += ('    - Created:    %s\n' % (convert_timestamp(entry['date'])))
+
+        if len(r_conflicts) > 0:
+            if len(m_conflicts) > 0:
+                report += "\n"  # add spacer
+            report += ('Replica Conflict Entries: %d\n' % (len(r_conflicts)))
+            if verbose:
+                for entry in r_conflicts:
+                    report += ('\n  - %s\n' % (entry['dn']))
+                    report += ('    - Conflict:   %s\n' % (entry['conflict']))
+                    report += ('    - Glue entry: %s\n' % (entry['glue']))
+                    report += ('    - Created:    %s\n' % (convert_timestamp(entry['date'])))
+        report += "\n"
+        return report
+    else:
+        return ""
+
+
+def get_tombstones(replica, opts):
+    ''' Return the number of tombstones
+    '''
+    paged_ctrl = SimplePagedResultsControl(True, size=opts['pagesize'], cookie='')
+    controls = [paged_ctrl]
+    req_pr_ctrl = controls[0]
+    count = 0
+
+    try:
+        msgid = replica.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE,
+                                   '(&(objectclass=nstombstone)(nstombstonecsn=*))',
+                                   ['dn'], serverctrls=controls)
+    except ldap.LDAPError as e:
+        print("Error: Failed to get tombstone entries: %s", str(e))
+        exit(1)
+
+    done = False
+    while not done:
+        rtype, rdata, rmsgid, rctrls = replica.result3(msgid)
+        count += len(rdata)
+
+        pctrls = [
+                c
+                for c in rctrls
+                if c.controlType == SimplePagedResultsControl.controlType
+                ]
+        if pctrls:
+            if pctrls[0].cookie:
+                # Copy cookie from response control to request control
+                req_pr_ctrl.cookie = pctrls[0].cookie
+                msgid = replica.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE,
+                                           '(&(objectclass=nstombstone)(nstombstonecsn=*))',
+                                           ['dn'], serverctrls=controls)
+            else:
+                done = True  # No more pages available
+        else:
+            done = True
+    return count
+
+
+def do_online_report(opts, output_file=None):
+    ''' Check for differences between two replicas
+    '''
+    m_done = False
+    r_done = False
+    done = False
+    report = {}
+    report['diff'] = []
+    report['m_missing'] = []
+    report['r_missing'] = []
+    report['m_count'] = 0
+    report['r_count'] = 0
+    report['mtombstones'] = 0
+    report['rtombstones'] = 0
+    rconflicts = []
+    mconflicts = []
+
+    # Fire off paged searches on Master and Replica
+    master, replica, opts = connect_to_replicas(opts)
+
+    print ('Start searching and comparing...')
+    paged_ctrl = SimplePagedResultsControl(True, size=opts['pagesize'], cookie='')
+    controls = [paged_ctrl]
+    req_pr_ctrl = controls[0]
+    try:
+        master_msgid = master.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE, "objectclass=*",
+                                         ['*', 'createtimestamp', 'nscpentrywsi', 'nsds5replconflict'],
+                                         serverctrls=controls)
+    except ldap.LDAPError as e:
+        print("Error: Failed to get Master entries: %s", str(e))
+        exit(1)
+    try:
+        replica_msgid = replica.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE, "objectclass=*",
+                                           ['*', 'createtimestamp', 'nscpentrywsi', 'nsds5replconflict'],
+                                           serverctrls=controls)
+    except ldap.LDAPError as e:
+        print("Error: Failed to get Replica entries: %s", str(e))
+        exit(1)
+
+    # Read the results and start comparing
+    while not m_done or not r_done:
+        if not m_done:
+            m_rtype, m_rdata, m_rmsgid, m_rctrls = master.result3(master_msgid)
+        elif not r_done:
+            m_rdata = []
+
+        if not r_done:
+            r_rtype, r_rdata, r_rmsgid, r_rctrls = replica.result3(replica_msgid)
+        elif not m_done:
+            r_rdata = []
+
+        # Convert entries
+        mresult = convert_entries(m_rdata)
+        rresult = convert_entries(r_rdata)
+        report['m_count'] += len(mresult['entries'])
+        report['m_count'] += len(mresult['conflicts'])
+        report['r_count'] += len(rresult['entries'])
+        report['r_count'] += len(rresult['conflicts'])
+        mconflicts += mresult['conflicts']
+        rconflicts += rresult['conflicts']
+
+        # Check for diffs
+        report = check_for_diffs(mresult['entries'], rresult['entries'], report, opts)
+
+        if not m_done:
+            # Master
+            m_pctrls = [
+                c
+                for c in m_rctrls
+                if c.controlType == SimplePagedResultsControl.controlType
+                ]
+            if m_pctrls:
+                if m_pctrls[0].cookie:
+                    # Copy cookie from response control to request control
+                    req_pr_ctrl.cookie = m_pctrls[0].cookie
+                    master_msgid = master.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE, "objectclass=*",
+                        ['*', 'createtimestamp', 'nscpentrywsi', 'nsds5replconflict'], serverctrls=controls)
+                else:
+                    m_done = True  # No more pages available
+            else:
+                m_done = True
+
+        if not r_done:
+            # Replica
+            r_pctrls = [
+                c
+                for c in r_rctrls
+                if c.controlType == SimplePagedResultsControl.controlType
+                ]
+
+            if r_pctrls:
+                if r_pctrls[0].cookie:
+                    # Copy cookie from response control to request control
+                    req_pr_ctrl.cookie = r_pctrls[0].cookie
+                    replica_msgid = replica.search_ext(opts['suffix'], ldap.SCOPE_SUBTREE, "objectclass=*",
+                        ['*', 'createtimestamp', 'nscpentrywsi', 'nsds5replconflict'], serverctrls=controls)
+                else:
+                    r_done = True  # No more pages available
+            else:
+                r_done = True
+
+    # Get conflicts & tombstones
+    report['conflict'] = get_conflict_report(mconflicts, rconflicts, opts['conflicts'])
+    report['mtombstones'] = get_tombstones(master, opts)
+    report['rtombstones'] = get_tombstones(replica, opts)
+    report['m_count'] += report['mtombstones']
+    report['r_count'] += report['rtombstones']
+
+    # Do the final report
+    print_online_report(report, opts, output_file)
+
+    # unbind
+    master.unbind_s()
+    replica.unbind_s()
+
+
+def main():
+    desc = ("""Replication Comparison Tool (v""" + VERSION + """).  This script """ +
+            """can be used to compare two replicas to see if they are in sync.""")
+
+    parser = argparse.ArgumentParser(description=desc)
+    parser.add_argument('-v', '--verbose', help='Verbose output', action='store_true', default=False, dest='verbose')
+    parser.add_argument('-o', '--outfile', help='The output file', dest='file', default=None)
+    parser.add_argument('-D', '--binddn', help='The Bind DN', dest='binddn', default="")
+    parser.add_argument('-w', '--bindpw', help='The Bind password', dest='bindpw', default="")
+    parser.add_argument('-m', '--master_url', help='The LDAP URL for the Master server (REQUIRED)',
+                        dest='murl', default=None)
+    parser.add_argument('-r', '--replica_url', help='The LDAP URL for the Replica server (REQUIRED)',
+                        dest='rurl', default=None)
+    parser.add_argument('-b', '--basedn', help='Replicated suffix (REQUIRED)', dest='suffix', default=None)
+    parser.add_argument('-l', '--lagtime', help='The amount of time to ignore inconsistencies (default 300 seconds)',
+                        dest='lag', default='300')
+    parser.add_argument('-c', '--conflicts', help='Display verbose conflict information', action='store_true',
+                        dest='conflicts', default=False)
+    parser.add_argument('-Z', '--certdir', help='The certificate database directory for secure connections',
+                        dest='certdir', default=None)
+    parser.add_argument('-i', '--ignore', help='Comma separated list of attributes to ignore',
+                        dest='ignore', default=None)
+    parser.add_argument('-p', '--pagesize', help='The paged result grouping size (default 500 entries)',
+                        dest='pagesize', default=500)
+    # Offline mode
+    parser.add_argument('-M', '--mldif', help='Master LDIF file (offline mode)',
+                        dest='mldif', default=None)
+    parser.add_argument('-R', '--rldif', help='Replica LDIF file (offline mode)',
+                        dest='rldif', default=None)
+
+    # Process the options
+    args = parser.parse_args()
+    opts = {}
+
+    # Check for required options
+    if ((args.mldif is not None and args.rldif is None) or
+        (args.mldif is None and args.rldif is not None)):
+            print("\n-------> Missing required options for offline mode!\n")
+            parser.print_help()
+            exit(1)
+    elif (args.mldif is None and
+          (args.suffix is None or
+           args.binddn is None or
+           args.bindpw is None or
+           args.murl is None or
+           args.rurl is None)):
+            print("\n-------> Missing required options for online mode!\n")
+            parser.print_help()
+            exit(1)
+
+    # Parse the ldap URLs
+    if args.murl is not None and args.rurl is not None:
+        # Parse Master url
+        murl = ldapurl.LDAPUrl(args.murl)
+        if not ldapurl.isLDAPUrl(args.murl):
+            print "Master LDAP URL is invalid"
+            exit(1)
+        if murl.urlscheme in VALID_PROTOCOLS:
+            opts['mprotocol'] = murl.urlscheme
+        else:
+            print('Unsupported ldap url protocol (%s) for Master, please use "ldaps" or "ldap"' %
+                  murl.urlscheme)
+        parts = murl.hostport.split(':')
+        if len(parts) == 0:
+            # ldap:///
+            opts['mhost'] = 'localhost'
+            opts['mport'] = '389'
+        if len(parts) == 1:
+            # ldap://host/
+            opts['mhost'] = parts[0]
+            opts['mport'] = '389'
+        else:
+            # ldap://host:port/
+            opts['mhost'] = parts[0]
+            opts['mport'] = parts[1]
+
+        # Parse Replica url
+        rurl = ldapurl.LDAPUrl(args.rurl)
+        if not ldapurl.isLDAPUrl(args.rurl):
+            print "Replica LDAP URL is invalid"
+            exit(1)
+        if rurl.urlscheme in VALID_PROTOCOLS:
+            opts['rprotocol'] = rurl.urlscheme
+        else:
+            print('Unsupported ldap url protocol (%s) for Replica, please use "ldaps" or "ldap"' %
+                  murl.urlscheme)
+        parts = rurl.hostport.split(':')
+        if len(parts) == 0:
+            # ldap:///
+            opts['rhost'] = 'localhost'
+            opts['rport'] = '389'
+        elif len(parts) == 1:
+            # ldap://host/
+            opts['rhost'] = parts[0]
+            opts['rport'] = '389'
+        else:
+            # ldap://host:port/
+            opts['rhost'] = parts[0]
+            opts['rport'] = parts[1]
+
+    # Initialize the options
+    opts['binddn'] = args.binddn
+    opts['bindpw'] = args.bindpw
+    opts['suffix'] = args.suffix
+    opts['certdir'] = args.certdir
+    opts['starttime'] = int(time.time())
+    opts['verbose'] = args.verbose
+    opts['mldif'] = args.mldif
+    opts['rldif'] = args.rldif
+    opts['pagesize'] = int(args.pagesize)
+    opts['conflicts'] = args.conflicts
+    opts['ignore'] = ['createtimestamp', 'nscpentrywsi']
+    if args.ignore:
+        opts['ignore'] = opts['ignore'] + args.ignore.split(',')
+    if args.mldif:
+        # We're offline - "lag" only applies to online mode
+        opts['lag'] = 0
+    else:
+        opts['lag'] = int(args.lag)
+
+    OUTPUT_FILE = None
+    if args.file:
+        # Write report to the file
+        try:
+            OUTPUT_FILE = open(args.file, "w")
+        except IOError:
+            print("Can't open file: " + args.file)
+            exit(1)
+
+    if opts['mldif'] is not None and opts['rldif'] is not None:
+        print ("Performing offline report...")
+        do_offline_report(opts, OUTPUT_FILE)
+    else:
+        print ("Performing online report...")
+        do_online_report(opts, OUTPUT_FILE)
+
+    if OUTPUT_FILE is not None:
+        print('Finished writing report to "%s"' % (args.file))
+        OUTPUT_FILE.close()
+
+if __name__ == '__main__':
+    main()
diff --git a/man/man8/repl-diff.py.8 b/man/man8/repl-diff.py.8
new file mode 100644
index 0000000..4578cc2
--- /dev/null
+++ b/man/man8/repl-diff.py.8
@@ -0,0 +1,85 @@
+.\"                                      Hey, EMACS: -*- nroff -*-
+.\" First parameter, NAME, should be all caps
+.\" Second parameter, SECTION, should be 1-8, maybe w/ subsection
+.\" other parameters are allowed: see man(7), man(1)
+.TH DBMON.SH 8 "Mar 20, 2017"
+.\" Please adjust this date whenever revising the manpage.
+.\"
+.\" Some roff macros, for reference:
+.\" .nh        disable hyphenation
+.\" .hy        enable hyphenation
+.\" .ad l      left justify
+.\" .ad b      justify to both left and right margins
+.\" .nf        disable filling
+.\" .fi        enable filling
+.\" .br        insert line break
+.\" .sp <n>    insert n+1 empty lines
+.\" for manpage-specific macros, see man(7)
+.SH NAME 
+repl-diff.py - Performs replication synchronization report between two replicas
+
+.SH SYNOPSIS
+repl-diff.py [-h] [-o FILE] [-D BINDDN] [-w BINDPW] [-m MURL]
+             [-r RURL] [-b SUFFIX] [-l LAG] [-Z CERTDIR]
+             [-i IGNORE] [-p PAGESIZE] [-M MLDIF] [-R RLDIF]
+
+.SH DESCRIPTION
+repl-diff.py has two operating modes: offline - which compares two LDIF files (generated by db2ldif -r), and online mode - which queries each server to gather the entries for comparisions.  The tool reports on missing entries, entry inconsistencies, tombstones, conflict entries, database RUVs, and entry counts.
+
+.SH OPTIONS
+
+A summary of options is included below:
+
+.TP
+.B \fB\-h\fR
+.br
+Display usage
+.TP
+.B \fB\-D\fR \fIRoot DN\fR
+The Directory Manager DN, or root DN.a (online mode)
+.TP
+.B \fB\-w\fR \fIPASSWORD\fR
+The Directory Manager password (online mode)
+.TP
+.B \fB\-m\fR \fILDAP_URL\fR
+The LDAP Url for the first replica (online mode)
+.TP
+.B \fB\-r\fR \fILDAP URL\fR
+The LDAP Url for the the second replica (online mode)
+.TP
+.B \fB\-b\fR \fISUFFIX\fR
+The replication suffix.  (online & offline)
+.TP
+.B \fB\-l\fR \fILag time\fR
+If an inconsistency is detected, and it is within this lag allowance it will *NOT* be reported.  (online mode)
+.TP
+.B \fB\-Z\fR \fICERT DIR\fR
+The directory containing a certificate database for StartTLS/SSL connections.  (online mode)
+.TP
+.B \fB\-i\fR \fIIGNORE LIST\fR
+Comma separated list of attributes to ignore in the report  (online & offline)
+.TP
+.B \fB\-M\fR \fILDIF FILE\fR
+The LDIF file for the first replica  (offline mode)
+.TP
+.B \fB\-R\fR \fILDIF FILE\fR
+The LDIF file for the second replica  (offline mode)
+.TP
+.B \fB\-p\fR \fIPAGE SIZE\fR
+The page size used for the paged result searches that the tool performs.  The default is 500.  (online mode)
+.TP
+.B \fB\-o\fR \fIOUTPUT FILE\fR
+The file to write the report to.  (online and offline)
+
+.SH EXAMPLE
+python repl-diff.py -D "cn=directory manager" -w PASSWORD -m ldap://myhost.domain.com:389 -r ldap://otherhost.domain.com:389 -b "dc=example,dc=com" -Z /etc/dirsrv/slapd-myinstance
+
+python repl-diff.py -b dc=example,dc=com -M /tmp/replicaA.ldif -R /tmp/replicaB.ldif
+
+.SH AUTHOR
+repl-diff.py was written by the 389 Project.
+.SH "REPORTING BUGS"
+Report bugs to https://pagure.io/389-ds-base/new_issue
+.SH COPYRIGHT
+Copyright \(co 2017 Red Hat, Inc.
+
-- 
2.9.3

