From 4f7c05e2879cee7d205531edb64b19ad799e20bd Mon Sep 17 00:00:00 2001 From: Mark Reynolds Date: Apr 18 2019 13:37:20 +0000 Subject: Ticket 50327 - Add replication conflict entry support to lib389/CLI Description: Added Conflict Entry and Glue entry classes to lib389, and updated dsconf to allow for conflict entry management. Made some other minor changes to mapped objects: - Added an attribute list option to display() - Added a recursive delete option to delete() https://pagure.io/389-ds-base/issue/50327 Reviewed by: firstyear, lkrispen, and spichugi(Thanks!!!) --- diff --git a/src/lib389/cli/dsconf b/src/lib389/cli/dsconf index 83bab82..f815162 100755 --- a/src/lib389/cli/dsconf +++ b/src/lib389/cli/dsconf @@ -31,6 +31,7 @@ from lib389.cli_conf import pwpolicy as cli_pwpolicy from lib389.cli_conf import backup as cli_backup from lib389.cli_conf import replication as cli_replication from lib389.cli_conf import chaining as cli_chaining +from lib389.cli_conf import conflicts as cli_repl_conflicts from lib389.cli_base import disconnect_instance, connect_instance from lib389.cli_base.dsrc import dsrc_to_ldap, dsrc_arg_concat from lib389.cli_base import setup_script_logger @@ -87,6 +88,7 @@ cli_pwpolicy.create_parser(subparsers) cli_replication.create_parser(subparsers) cli_sasl.create_parser(subparsers) cli_schema.create_parser(subparsers) +cli_repl_conflicts.create_parser(subparsers) argcomplete.autocomplete(parser) diff --git a/src/lib389/lib389/_mapped_object.py b/src/lib389/lib389/_mapped_object.py index e96e0fe..f1a54c3 100644 --- a/src/lib389/lib389/_mapped_object.py +++ b/src/lib389/lib389/_mapped_object.py @@ -1,5 +1,5 @@ # --- BEGIN COPYRIGHT BLOCK --- -# Copyright (C) 2016 Red Hat, Inc. +# Copyright (C) 2019 Red Hat, Inc. # Copyright (C) 2019 William Brown # All rights reserved. # @@ -129,7 +129,9 @@ class DSLdapObject(DSLogging): :returns: Entry object """ - return self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=["*"], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + return self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=["*"], + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure')[0] def exists(self): """Check if the entry exists @@ -138,19 +140,22 @@ class DSLdapObject(DSLogging): """ try: - self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrsonly=1, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrsonly=1, + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure') except ldap.NO_SUCH_OBJECT: return False return True - def display(self): + def display(self, attrlist=['*']): """Get an entry but represent it as a string LDIF :returns: LDIF formatted string """ - - e = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=["*"], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + e = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=attrlist, + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure')[0] return e.__repr__() def display_attr(self, attr): @@ -227,7 +232,9 @@ class DSLdapObject(DSLogging): raise ValueError("Invalid state. Cannot get presence on instance that is not ONLINE") self._log.debug("%s present(%r) %s" % (self._dn, attr, value)) - e = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=[attr, ], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + e = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=[attr, ], + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure')[0] values = self.get_attr_vals_bytes(attr) self._log.debug("%s contains %s" % (self._dn, values)) @@ -280,7 +287,8 @@ class DSLdapObject(DSLogging): else: value = [ensure_bytes(arg[1])] mods.append((ldap.MOD_REPLACE, ensure_str(arg[0]), value)) - return self._instance.modify_ext_s(self._dn, mods, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + return self._instance.modify_ext_s(self._dn, mods, serverctrls=self._server_controls, + clientctrls=self._client_controls, escapehatch='i am sure') # This needs to work on key + val, and key def remove(self, key, value): @@ -375,7 +383,8 @@ class DSLdapObject(DSLogging): value = [ensure_bytes(value)] return self._instance.modify_ext_s(self._dn, [(action, key, value)], - serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure') def apply_mods(self, mods): """Perform modification operation using several mods at once @@ -415,7 +424,7 @@ class DSLdapObject(DSLogging): raise ValueError('Too many arguments in the mod op') return self._instance.modify_ext_s(self._dn, mod_list, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') - def _unsafe_compare_attribute(self, attr, values): + def _unsafe_compare_attribute(self, other): """Compare two attributes from two objects. This is currently marked unsafe as it's not complete yet. @@ -430,12 +439,7 @@ class DSLdapObject(DSLogging): To allow schema aware checking, we need to call ldap compare extop here. """ - return all([ - self._instance.compare_ext_s(self._dn, attr, value, serverctrls=self._server_controls, - clientctrls=self._client_controls, escapehatch='i am sure') - for value in values - ]) - + pass @classmethod def compare(cls, obj1, obj2): @@ -474,14 +478,10 @@ class DSLdapObject(DSLogging): if set(obj1_attrs.keys()) != set(obj2_attrs.keys()): obj1._log.debug("%s != %s" % (obj1_attrs.keys(), obj2_attrs.keys())) return False - obj1._log.debug(sorted(obj1_attrs.keys())) # Check the values of each key # using obj1_attrs.keys() because obj1_attrs.iterkleys() is not supported in python3 for key in obj1_attrs.keys(): - # Check if they are offline/online? - # if set(obj1_attrs[key]) != set(obj2_attrs[key]): - obj1._log.debug("checking %s: %s ..." % (key, obj2_attrs[key])) - if not obj1._unsafe_compare_attribute(key, obj2_attrs[key]): + if set(obj1_attrs[key]) != set(obj2_attrs[key]): obj1._log.debug(" v-- %s != %s" % (key, key)) obj1._log.debug("%s != %s" % (obj1_attrs[key], obj2_attrs[key])) return False @@ -503,7 +503,7 @@ class DSLdapObject(DSLogging): cx = [x.lower() for x in self._compare_exclude] compare_attrs = set(all_attrs_lower.keys()) - set(cx) - compare_attrs_dict = {attr.lower():all_attrs_lower[attr] for attr in compare_attrs} + compare_attrs_dict = {attr.lower(): all_attrs_lower[attr] for attr in compare_attrs} return compare_attrs_dict @@ -518,7 +518,9 @@ class DSLdapObject(DSLogging): raise ValueError("Invalid state. Cannot get properties on instance that is not ONLINE") else: # retrieving real(*) and operational attributes(+) - attrs_entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=["*", "+"], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + attrs_entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, + attrlist=["*", "+"], serverctrls=self._server_controls, + clientctrls=self._client_controls, escapehatch='i am sure')[0] # getting dict from 'entry' object attrs_dict = attrs_entry.data # Should we normalise the attr names here to lower()? @@ -530,14 +532,18 @@ class DSLdapObject(DSLogging): if self._instance.state != DIRSRV_STATE_ONLINE: raise ValueError("Invalid state. Cannot get properties on instance that is not ONLINE") else: - entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=keys, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, + attrlist=keys, serverctrls=self._server_controls, + clientctrls=self._client_controls, escapehatch='i am sure')[0] return entry.getValuesSet(keys) def get_attrs_vals_utf8(self, keys, use_json=False): self._log.debug("%s get_attrs_vals_utf8(%r)" % (self._dn, keys)) if self._instance.state != DIRSRV_STATE_ONLINE: raise ValueError("Invalid state. Cannot get properties on instance that is not ONLINE") - entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=keys, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=keys, + serverctrls=self._server_controls, clientctrls=self._client_controls, + escapehatch='i am sure')[0] vset = entry.getValuesSet(keys) r = {} for (k, vo) in vset.items(): @@ -554,7 +560,9 @@ class DSLdapObject(DSLogging): else: # It would be good to prevent the entry code intercepting this .... # We have to do this in this method, because else we ignore the scope base. - entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=[key], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, + attrlist=[key], serverctrls=self._server_controls, + clientctrls=self._client_controls, escapehatch='i am sure')[0] vals = entry.getValues(key) if use_json: result = {key: []} @@ -572,7 +580,9 @@ class DSLdapObject(DSLogging): # In the future, I plan to add a mode where if local == true, we # can use get on dse.ldif to get values offline. else: - entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, attrlist=[key], serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure')[0] + entry = self._instance.search_ext_s(self._dn, ldap.SCOPE_BASE, self._object_filter, + attrlist=[key], serverctrls=self._server_controls, + clientctrls=self._client_controls, escapehatch='i am sure')[0] return entry.getValue(key) def get_attr_val_bytes(self, key, use_json=False): @@ -679,7 +689,7 @@ class DSLdapObject(DSLogging): pass # Modifies the DN of an entry to the new fqdn provided - def rename(self, new_rdn, newsuperior=None): + def rename(self, new_rdn, newsuperior=None, deloldrdn=True): """Renames the object within the tree. If you provide a newsuperior, this will move the object in the tree. @@ -700,9 +710,12 @@ class DSLdapObject(DSLogging): # and the superior as the base (if it changed) if self._protected: return - self._instance.rename_s(self._dn, new_rdn, newsuperior, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + + self._instance.rename_s(self._dn, new_rdn, newsuperior, + serverctrls=self._server_controls, clientctrls=self._client_controls, + delold=deloldrdn, escapehatch='i am sure') search_base = self._basedn - if newsuperior != None: + if newsuperior is not None: # Well, the new DN should be rdn + newsuperior. self._dn = '%s,%s' % (new_rdn, newsuperior) else: @@ -714,7 +727,7 @@ class DSLdapObject(DSLogging): # assert we actually got the change right .... - def delete(self): + def delete(self, recursive=False): """Deletes the object defined by self._dn. This can be changed with the self._protected flag! """ @@ -722,7 +735,13 @@ class DSLdapObject(DSLogging): self._log.debug("%s delete" % (self._dn)) if not self._protected: # Is there a way to mark this as offline and kill it - self._instance.delete_ext_s(self._dn, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + if recursive: + filterstr = "(|(objectclass=*)(objectclass=ldapsubentry))" + ents = self._instance.search_s(self._dn, ldap.SCOPE_SUBTREE, filterstr, escapehatch='i am sure') + for ent in sorted(ents, key=lambda e: len(e.dn), reverse=True): + self._instance.delete_ext_s(ent.dn, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + else: + self._instance.delete_ext_s(self._dn, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') def _validate(self, rdn, properties, basedn): """Used to validate a create request. @@ -1121,7 +1140,7 @@ class DSLdapObjects(DSLogging): def filter(self, search): # This will yield and & filter for objectClass with as many terms as needed. - search_filter = _gen_and([self._get_objectclass_filter(),search]) + search_filter = _gen_and([self._get_objectclass_filter(), search]) self._log.debug('list filter = %s' % search_filter) try: results = self._instance.search_ext_s( @@ -1137,5 +1156,3 @@ class DSLdapObjects(DSLogging): # There are no objects to select from, se we return an empty array insts = [] return insts - - diff --git a/src/lib389/lib389/cli_conf/conflicts.py b/src/lib389/lib389/cli_conf/conflicts.py new file mode 100644 index 0000000..620f68c --- /dev/null +++ b/src/lib389/lib389/cli_conf/conflicts.py @@ -0,0 +1,127 @@ +# --- BEGIN COPYRIGHT BLOCK --- +# Copyright (C) 2019 Red Hat, Inc. +# All rights reserved. +# +# License: GPL (version 3 or any later version). +# See LICENSE for details. +# --- END COPYRIGHT BLOCK --- + +import json +from lib389.conflicts import (ConflictEntries, ConflictEntry, GlueEntries, GlueEntry) + +conflict_attrs = ['nsds5replconflict', '*'] + + +def list_conflicts(inst, basedn, log, args): + conflicts = ConflictEntries(inst, args.suffix).list() + if args.json: + results = [] + for conflict in conflicts: + results.append(json.loads(conflict.get_all_attrs_json())) + log.info(json.dumps({'type': 'list', 'items': results})) + else: + if len(conflicts) > 0: + for conflict in conflicts: + log.info(conflict.display(conflict_attrs)) + else: + log.info("There were no conflict entries found under the suffix") + + +def cmp_conflict(inst, basedn, log, args): + conflict = ConflictEntry(inst, args.DN) + valid_entry = conflict.get_valid_entry() + + if args.json: + results = [] + results.append(json.loads(conflict.get_all_attrs_json())) + results.append(json.loads(valid_entry.get_all_attrs_json())) + log.info(json.dumps({'type': 'list', 'items': results})) + else: + log.info("Conflict Entry:\n") + log.info(conflict.display(conflict_attrs)) + log.info("Valid Entry:\n") + log.info(valid_entry.display(conflict_attrs)) + + +def del_conflict(inst, basedn, log, args): + conflict = ConflictEntry(inst, args.DN) + conflict.delete() + + +def swap_conflict(inst, basedn, log, args): + conflict = ConflictEntry(inst, args.DN) + conflict.swap() + + +def convert_conflict(inst, basedn, log, args): + conflict = ConflictEntry(inst, args.DN) + conflict.convert(args.new_rdn) + + +def list_glue(inst, basedn, log, args): + glues = GlueEntries(inst, args.suffix).list() + if args.json: + results = [] + for glue in glues: + results.append(json.loads(glue.get_all_attrs_json())) + log.info(json.dumps({'type': 'list', 'items': results})) + else: + if len(glues) > 0: + for glue in glues: + log.info(glue.display(conflict_attrs)) + else: + log.info("There were no glue entries found under the suffix") + + +def del_glue(inst, basedn, log, args): + glue = GlueEntry(inst, args.DN) + glue.delete_all() + + +def convert_glue(inst, basedn, log, args): + glue = GlueEntry(inst, args.DN) + glue.convert() + + +def create_parser(subparsers): + conflict_parser = subparsers.add_parser('repl-conflict', help="Manage replication conflicts") + subcommands = conflict_parser.add_subparsers(help='action') + + # coinflict entry arguments + list_parser = subcommands.add_parser('list', help="List conflict entries") + list_parser.add_argument('suffix', help='The backend name, or suffix, to look for conflict entries') + list_parser.set_defaults(func=list_conflicts) + + cmp_parser = subcommands.add_parser('compare', help="Compare the conflict entry with its valid counterpart") + cmp_parser.add_argument('DN', help='The DN of the conflict entry') + cmp_parser.set_defaults(func=cmp_conflict) + + del_parser = subcommands.add_parser('delete', help="Delete a conflict entry") + del_parser.add_argument('DN', help='The DN of the conflict entry') + del_parser.set_defaults(func=del_conflict) + + replace_parser = subcommands.add_parser('swap', help="Replace the valid entry with the conflict entry") + replace_parser.add_argument('DN', help='The DN of the conflict entry') + replace_parser.set_defaults(func=swap_conflict) + + replace_parser = subcommands.add_parser('convert', help="Convert the conflict entry to a valid entry, " + "while keeping the original valid entry counterpart. " + "This requires that the converted conflict entry have " + "a new RDN value. For example: \"cn=my_new_rdn_value\".") + replace_parser.add_argument('DN', help='The DN of the conflict entry') + replace_parser.add_argument('--new-rdn', required=True, help="The new RDN for the converted conflict entry. " + "For example: \"cn=my_new_rdn_value\"") + replace_parser.set_defaults(func=convert_conflict) + + # Glue entry arguments + list_glue_parser = subcommands.add_parser('list-glue', help="List replication glue entries") + list_glue_parser.add_argument('suffix', help='The backend name, or suffix, to look for glue entries') + list_glue_parser.set_defaults(func=list_glue) + + del_glue_parser = subcommands.add_parser('delete-glue', help="Delete the glue entry and its child entries") + del_glue_parser.add_argument('DN', help='The DN of the glue entry') + del_glue_parser.set_defaults(func=del_glue) + + convert_glue_parser = subcommands.add_parser('convert-glue', help="Convert the glue entry into a regular entry") + convert_glue_parser.add_argument('DN', help='The DN of the glue entry') + convert_glue_parser.set_defaults(func=convert_glue) diff --git a/src/lib389/lib389/cli_conf/monitor.py b/src/lib389/lib389/cli_conf/monitor.py index a704bea..53637e1 100644 --- a/src/lib389/lib389/cli_conf/monitor.py +++ b/src/lib389/lib389/cli_conf/monitor.py @@ -1,5 +1,6 @@ # --- BEGIN COPYRIGHT BLOCK --- # Copyright (C) 2019 William Brown +# Copyright (C) 2019 Red Hat, Inc. # All rights reserved. # # License: GPL (version 3 or any later version). diff --git a/src/lib389/lib389/conflicts.py b/src/lib389/lib389/conflicts.py new file mode 100644 index 0000000..b1f86e0 --- /dev/null +++ b/src/lib389/lib389/conflicts.py @@ -0,0 +1,175 @@ +# --- BEGIN COPYRIGHT BLOCK --- +# Copyright (C) 2019 Red Hat, Inc. +# All rights reserved. +# +# License: GPL (version 3 or any later version). +# See LICENSE for details. +# --- END COPYRIGHT BLOCK --- +# + +import ldap +from lib389._mapped_object import DSLdapObject, DSLdapObjects, _gen_filter + + +class ConflictEntry(DSLdapObject): + """A replication conflict entry + + :param instance: An instance + :type instance: lib389.DirSrv + :param dn: The DN of the conflict entry + :type dn: str + """ + def __init__(self, instance, dn=None): + super(ConflictEntry, self).__init__(instance, dn) + self._rdn_attribute = 'cn' + self._create_objectclasses = ['ldapsubentry'] + self._protected = False + self._object_filter = '(objectclass=ldapsubentry)' + + def convert(self, new_rdn): + """Convert conflict entry to a vlid entry, but we need to + give the conflict entry a new rdn since we are not replacing + the existing valid counterpart entry. + """ + + # Get the conflict entry info + conflict_value = self.get_attr_val_utf8('nsds5ReplConflict') + entry_dn = conflict_value.split(' ', 3)[2] + entry_rdn = ldap.explode_dn(entry_dn, 1)[0] + rdn_attr = entry_dn.split('=', 1)[0] + + # Rename conflict entry + self.rename(new_rdn, deloldrdn=False) + + # Cleanup entry + self.remove(rdn_attr, entry_rdn) + if self.present('objectclass', 'ldapsubentry'): + self.remove('objectclass', 'ldapsubentry') + self.remove_all('nsds5ReplConflict') + + def swap(self): + """Make the conflict entry the real valid entry. Delete old valid entry, + and rename the conflict + """ + + # Get the conflict entry info + conflict_value = self.get_attr_val_utf8('nsds5ReplConflict') + entry_dn = conflict_value.split(' ', 3)[2] + entry_rdn = ldap.explode_dn(entry_dn, 1)[0] + + # Gather the RDN details + rdn_attr = entry_dn.split('=', 1)[0] + new_rdn = "{}={}".format(rdn_attr, entry_rdn) + tmp_rdn = new_rdn + 'tmp' + + # Delete valid entry (to be replaced by conflict entry) + original_entry = DSLdapObject(self._instance, dn=entry_dn) + original_entry._protected = False + original_entry.delete() + + # Rename conflict entry to tmp rdn so we can clean up the rdn attr + self.rename(tmp_rdn, deloldrdn=False) + + # Cleanup entry + self.remove(rdn_attr, entry_rdn) + if self.present('objectclass', 'ldapsubentry'): + self.remove('objectclass', 'ldapsubentry') + self.remove_all('nsds5ReplConflict') + + # Rename to the final/correct rdn + self.rename(new_rdn, deloldrdn=True) + + def get_valid_entry(self): + """Get the conflict entry's valid counterpart entry + """ + # Get the conflict entry info + conflict_value = self.get_attr_val_utf8('nsds5ReplConflict') + entry_dn = conflict_value.split(' ', 3)[2] + + # Get the valid entry + return DSLdapObject(self._instance, dn=entry_dn) + + +class ConflictEntries(DSLdapObjects): + """Represents the set of tombstone objects that may exist on + this replica. Tombstones are locally generated, so they are + unique to individual masters, and may or may not correlate + to tombstones on other masters. + + :param instance: An instance + :type instance: lib389.DirSrv + :param basedn: Tree to search for tombstones in + :type basedn: str + """ + def __init__(self, instance, basedn): + super(ConflictEntries, self).__init__(instance) + self._objectclasses = ['ldapsubentry'] + # Try some common ones .... + self._filterattrs = ['nsds5replconflict', 'objectclass'] + self._childobject = ConflictEntry + self._basedn = basedn + + def _get_objectclass_filter(self): + return "(&(objectclass=ldapsubentry)(nsds5replconflict=*))" + + +class GlueEntry(DSLdapObject): + """A replication glue entry + + :param instance: An instance + :type instance: lib389.DirSrv + :param dn: The DN of the conflict entry + :type dn: str + """ + def __init__(self, instance, dn=None): + super(GlueEntry, self).__init__(instance, dn) + self._rdn_attribute = '' + self._create_objectclasses = ['glue'] + self._protected = False + self._object_filter = '(objectclass=glue)' + + def convert(self): + """Convert entry into real entry + """ + self.remove_all('nsds5replconflict') + self.remove('objectclass', 'glue') + + def delete_all(self): + """Remove glue entry and its children. Depending on the situation the URP + mechanism can turn the parent glue entry into a tombstone before we get + a chance to delete it. This results in a NO_SUCH_OBJECT exception + """ + delete_count = 0 + filterstr = "(|(objectclass=*)(objectclass=ldapsubentry))" + ents = self._instance.search_s(self._dn, ldap.SCOPE_SUBTREE, filterstr, escapehatch='i am sure') + for ent in sorted(ents, key=lambda e: len(e.dn), reverse=True): + try: + self._instance.delete_ext_s(ent.dn, serverctrls=self._server_controls, clientctrls=self._client_controls, escapehatch='i am sure') + delete_count += 1 + except ldap.NO_SUCH_OBJECT as e: + if len(ents) > 0 and delete_count == (len(ents) - 1): + # This is the parent glue entry - it was removed by URP + pass + else: + raise e + + +class GlueEntries(DSLdapObjects): + """Represents the set of glue entries that may exist on + this replica. + + :param instance: An instance + :type instance: lib389.DirSrv + :param basedn: Tree to search for tombstones in + :type basedn: str + """ + def __init__(self, instance, basedn): + super(GlueEntries, self).__init__(instance) + self._objectclasses = ['glue'] + # Try some common ones .... + self._filterattrs = ['nsds5replconflict', 'objectclass'] + self._childobject = GlueEntry + self._basedn = basedn + + def _get_objectclass_filter(self): + return _gen_filter(['objectclass'], ['glue']) diff --git a/src/lib389/lib389/tests/cli/conf_conflicts_test.py b/src/lib389/lib389/tests/cli/conf_conflicts_test.py new file mode 100644 index 0000000..1624c28 --- /dev/null +++ b/src/lib389/lib389/tests/cli/conf_conflicts_test.py @@ -0,0 +1,161 @@ +# --- BEGIN COPYRIGHT BLOCK --- +# Copyright (C) 2018 Red Hat, Inc. +# All rights reserved. +# +# License: GPL (version 3 or any later version). +# See LICENSE for details. +# --- END COPYRIGHT BLOCK --- + + +import io +import sys +import pytest +import time +import json +from lib389.cli_base import LogCapture, FakeArgs +from lib389.utils import * +from lib389._constants import * +from lib389.idm.nscontainer import nsContainers +from lib389.topologies import topology_m2 as topo +from lib389.cli_conf.conflicts import (list_conflicts, cmp_conflict, del_conflict, swap_conflict, + convert_conflict, list_glue, del_glue, convert_glue) +from lib389.utils import ds_is_older +pytestmark = pytest.mark.skipif(ds_is_older('1.4.0'), reason="Not implemented") + + +def _create_container(inst, dn, name): + """Creates container entry""" + containers = nsContainers(inst, dn) + container = containers.create(properties={'cn': name}) + time.sleep(1) + return container + + +def _delete_container(container): + """Deletes container entry""" + container.delete() + time.sleep(1) + + +def test_conflict_cli(topo): + """Test manageing replication conflict entries + :id: 800f432a-52ab-4661-ac66-a2bdd9b984d8 + :setup: two masters + :steps: + 1. Create replication conflict entries + 2. List conflicts + 3. Compare conflict entry + 4. Delete conflict + 5. Resurrect conflict + 6. Swap conflict + 7. List glue entry + 8. Delete glue entry + 9. Convert glue entry + + :expectedresults: + 1. Success + 2. Success + 3. Success + 4. Success + 5. Success + 6. Success + 7. Success + 8. Success + 9. Success + 10. Success + """ + + # Setup our default parameters for CLI functions + topo.logcap = LogCapture() + sys.stdout = io.StringIO() + args = FakeArgs() + args.DN = "" + args.suffix = DEFAULT_SUFFIX + args.json = True + + m1 = topo.ms["master1"] + m2 = topo.ms["master2"] + + topo.pause_all_replicas() + + # Create entries + _create_container(m1, DEFAULT_SUFFIX, 'conflict_parent1') + _create_container(m2, DEFAULT_SUFFIX, 'conflict_parent1') + _create_container(m1, DEFAULT_SUFFIX, 'conflict_parent2') + _create_container(m2, DEFAULT_SUFFIX, 'conflict_parent2') + cont_parent_m1 = _create_container(m1, DEFAULT_SUFFIX, 'conflict_parent3') + cont_parent_m2 = _create_container(m2, DEFAULT_SUFFIX, 'conflict_parent3') + cont_glue_m1 = _create_container(m1, DEFAULT_SUFFIX, 'conflict_parent4') + cont_glue_m2 = _create_container(m2, DEFAULT_SUFFIX, 'conflict_parent4') + + # Create the conflicts + _delete_container(cont_parent_m1) + _create_container(m2, cont_parent_m2.dn, 'conflict_child1') + _delete_container(cont_glue_m1) + _create_container(m2, cont_glue_m2.dn, 'conflict_child2') + + # Resume replication + topo.resume_all_replicas() + time.sleep(5) + + # Test "list" + list_conflicts(m2, None, topo.logcap.log, args) + conflicts = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(conflicts['items']) == 4 + conflict_1_DN = conflicts['items'][0]['dn'] + conflict_2_DN = conflicts['items'][1]['dn'] + conflict_3_DN = conflicts['items'][2]['dn'] + topo.logcap.flush() + + # Test compare + args.DN = conflict_1_DN + cmp_conflict(m2, None, topo.logcap.log, args) + conflicts = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(conflicts['items']) == 2 + topo.logcap.flush() + + # Test delete + del_conflict(m2, None, topo.logcap.log, args) + list_conflicts(m2, None, topo.logcap.log, args) + conflicts = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(conflicts['items']) == 3 + topo.logcap.flush() + + # Test swap + args.DN = conflict_2_DN + swap_conflict(m2, None, topo.logcap.log, args) + list_conflicts(m2, None, topo.logcap.log, args) + conflicts = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(conflicts['items']) == 2 + topo.logcap.flush() + + # Test conflict convert + args.DN = conflict_3_DN + args.new_rdn = "cn=testing convert" + convert_conflict(m2, None, topo.logcap.log, args) + list_conflicts(m2, None, topo.logcap.log, args) + conflicts = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(conflicts['items']) == 1 + topo.logcap.flush() + + # Test list glue entries + list_glue(m2, None, topo.logcap.log, args) + glues = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(glues['items']) == 2 + topo.logcap.flush() + + # Test delete glue entries + args.DN = "cn=conflict_parent3,dc=example,dc=com" + del_glue(m2, None, topo.logcap.log, args) + list_glue(m2, None, topo.logcap.log, args) + glues = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(glues['items']) == 1 + topo.logcap.flush() + + # Test convert glue entries + args.DN = "cn=conflict_parent4,dc=example,dc=com" + convert_glue(m2, None, topo.logcap.log, args) + list_glue(m2, None, topo.logcap.log, args) + glues = json.loads(topo.logcap.outputs[0].getMessage()) + assert len(glues['items']) == 0 + topo.logcap.flush()