From 341eeabdfbe92a52b68a997cbfad8df33689dc83 Mon Sep 17 00:00:00 2001 From: William Brown Date: Jan 28 2019 22:40:35 +0000 Subject: Ticket 50151 - lib389 support cli add/replace/delete on objects Bug Description: We need a generic way to add/replace/delete on objects, that is not ldif. Ldif is wildly inaccessible and hard to use. Fix Description: Add a "modify" generic to cli_base, that is used by user. It supports a syntax of: modify :: An example is: ... user modify demo_user add:objectclass:nsMemberOf These can have many modifications in a single transaction: user modify demo_user add:objectclass:nsMemberOf add:description:test https://pagure.io/389-ds-base/issue/50151 Author: William Brown Review by: spichugi, mreynolds, lkrispen (Thanks!) --- diff --git a/src/lib389/lib389/_mapped_object.py b/src/lib389/lib389/_mapped_object.py index 06a2821..bc0c8e6 100644 --- a/src/lib389/lib389/_mapped_object.py +++ b/src/lib389/lib389/_mapped_object.py @@ -388,11 +388,15 @@ class DSLdapObject(DSLogging): if len(mod) < 2: # Error raise ValueError('Not enough arguments in the mod op') - elif len(mod) == 2: # delete all attributes action + elif len(mod) == 2: # no action + # This hack exists because the original lib389 Entry type + # does odd things. action, key = mod if action != ldap.MOD_DELETE: - raise ValueError('Not enough arguments in the mod op') - mod_list.append((action, key, None)) + raise ValueError('Only MOD_DELETE takes two arguments %s' % mod) + value = None + # Just add the raw mod, because we don't have a value + mod_list.append((action, key, value)) elif len(mod) == 3: action, key, value = mod if action != ldap.MOD_REPLACE and \ diff --git a/src/lib389/lib389/cli_base/__init__.py b/src/lib389/lib389/cli_base/__init__.py index 3e2cdcc..14cb999 100644 --- a/src/lib389/lib389/cli_base/__init__.py +++ b/src/lib389/lib389/cli_base/__init__.py @@ -271,6 +271,47 @@ def _generic_del_attr(inst, basedn, log, manager_class, args=None): # Missing value raise ValueError("Missing attribute to delete") +def _generic_modify_change_to_mod(change): + values = change.split(":") + if len(values) <= 2: + raise ValueError("Not enough arguments in '%s'. action:attribute:value" % change) + elif len(values) >= 4: + raise ValueError("Too many arguments in '%s'. %s:attribute:value expected" % (change, values[0])) + elif len(values[1]) == 0: + raise ValueError("Invalid empty attribute name in '%s'." % change) + # Return a tuple + if values[0] == 'add': + if len(values[2]) == 0: + raise ValueError("Invalid empty value in '%s'." % change) + return (ldap.MOD_ADD, values[1], values[2]) + elif values[0] == 'delete': + if len(values[2]) == 0: + return (ldap.MOD_DELETE, values[1]) + return (ldap.MOD_DELETE, values[1], values[2]) + elif values[0] == 'replace': + if len(values[2]) == 0: + raise ValueError("Invalid empty value in '%s'." % change) + return (ldap.MOD_REPLACE, values[1], values[2]) + else: + raise ValueError("Unknown action '%s'. Expected add, delete, replace" % change) + +def _generic_modify(inst, basedn, log, manager_class, selector, args=None): + # Here, we should have already selected the type etc. mc should be a + # type of DSLdapObject (singular) + mc = manager_class(inst, basedn) + # Get the object + if args and args.changes: + o = mc.get(selector) + # Now parse the series of arguments. + # Turn them into mod lists. See apply_mods. + mods = [_generic_modify_change_to_mod(x) for x in args.changes] + log.debug("Requested mods: %s" % mods) + # Now push them to dsldapobject to modify + o.apply_mods(mods) + print('Successfully modified %s' % o.dn) + else: + raise ValueError("Missing modify actions to perform.") + class LogCapture(logging.Handler): """ diff --git a/src/lib389/lib389/cli_idm/user.py b/src/lib389/lib389/cli_idm/user.py index 7de9275..9cd5005 100644 --- a/src/lib389/lib389/cli_idm/user.py +++ b/src/lib389/lib389/cli_idm/user.py @@ -8,7 +8,7 @@ import argparse from lib389.idm.user import nsUserAccount, nsUserAccounts -from lib389.cli_base import populate_attr_arguments +from lib389.cli_base import populate_attr_arguments, _generic_modify from lib389.cli_idm import ( _generic_list, _generic_get, @@ -48,6 +48,10 @@ def delete(inst, basedn, log, args, warn=True): _warn(dn, msg="Deleting %s %s" % (SINGULAR.__name__, dn)) _generic_delete(inst, basedn, log.getChild('_generic_delete'), SINGULAR, dn, args) +def modify(inst, basedn, log, args, warn=True): + rdn = _get_arg( args.selector, msg="Enter %s to retrieve" % RDN) + _generic_modify(inst, basedn, log.getChild('_generic_modify'), MANY, rdn, args) + def status(inst, basedn, log, args): uid = _get_arg( args.uid, msg="Enter %s to check" % RDN) uas = MANY(inst, basedn) @@ -90,6 +94,11 @@ def create_parser(subparsers): create_parser.set_defaults(func=create) populate_attr_arguments(create_parser, SINGULAR._must_attributes) + modify_parser = subcommands.add_parser('modify', help='modify :: ...') + modify_parser.set_defaults(func=modify) + modify_parser.add_argument('selector', nargs=1, help='The uid to modify') + modify_parser.add_argument('changes', nargs='+', help="A list of changes to apply in format: ::") + delete_parser = subcommands.add_parser('delete', help='deletes the object') delete_parser.set_defaults(func=delete) delete_parser.add_argument('dn', nargs='?', help='The dn to delete') diff --git a/src/lib389/lib389/tests/cli/idm_user_modify_test.py b/src/lib389/lib389/tests/cli/idm_user_modify_test.py new file mode 100644 index 0000000..0ab3269 --- /dev/null +++ b/src/lib389/lib389/tests/cli/idm_user_modify_test.py @@ -0,0 +1,95 @@ +# --- BEGIN COPYRIGHT BLOCK --- +# Copyright (C) 2019 William Brown +# All rights reserved. +# +# License: GPL (version 3 or any later version). +# See LICENSE for details. +# --- END COPYRIGHT BLOCK --- + +import pytest +import ldap + +from lib389._constants import DEFAULT_SUFFIX, INSTALL_LATEST_CONFIG + +from lib389.cli_conf.backend import backend_create +from lib389.cli_idm.initialise import initialise +from lib389.cli_idm.user import create, modify + +from lib389.cli_base import LogCapture, FakeArgs +from lib389.tests.cli import topology + +from lib389.utils import ds_is_older +pytestmark = pytest.mark.skipif(ds_is_older('1.4.0'), reason="Not implemented") + +# Topology is pulled from __init__.py +def test_user_modify(topology): + be_args = FakeArgs() + + be_args.be_name = 'userRoot' + be_args.suffix = DEFAULT_SUFFIX + be_args.parent_suffix = None + be_args.create_entries = False + backend_create(topology.standalone, None, topology.logcap.log, be_args) + + # And add the skeleton objects. + init_args = FakeArgs() + init_args.version = INSTALL_LATEST_CONFIG + initialise(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, init_args) + + # Check that our modify parser works. Modify statements are such as: + # "add:attr:value". Replace is the exception as "replace:attr:old:new" + + # Check bad syntax + modify_args = FakeArgs() + modify_args.selector = "demo_user" + modify_args.changes = ["tnaohtnsuahtnsouhtns"] + + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["add:attr:"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["add:attr"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["replace::"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["replace:attr::new"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["delete:attr:old:new"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + # Check that even a single bad value causes error + modify_args.changes = ["add:description:goodvalue", "add:attr:"] + with pytest.raises(ValueError): + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + # check good syntax + modify_args.changes = ["add:description:testvalue"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["replace:description:newvalue"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["delete:description:newvalue"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["add:description:testvalue"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + modify_args.changes = ["delete:description:"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + + # check mixed type, with multiple actions + + modify_args.changes = ["add:objectclass:nsMemberOf", "add:description:anothervalue"] + modify(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, modify_args) + diff --git a/src/lib389/lib389/tests/cli/idm_user_test.py b/src/lib389/lib389/tests/cli/idm_user_test.py index fdbc17d..8d12737 100644 --- a/src/lib389/lib389/tests/cli/idm_user_test.py +++ b/src/lib389/lib389/tests/cli/idm_user_test.py @@ -1,5 +1,6 @@ # --- BEGIN COPYRIGHT BLOCK --- # Copyright (C) 2017 Red Hat, Inc. +# Copyright (C) 2019 William Brown # All rights reserved. # # License: GPL (version 3 or any later version). @@ -25,8 +26,10 @@ pytestmark = pytest.mark.skipif(ds_is_older('1.4.0'), reason="Not implemented") def test_user_tasks(topology): be_args = FakeArgs() - be_args.cn = 'userRoot' - be_args.nsslapd_suffix = DEFAULT_SUFFIX + be_args.be_name = 'userRoot' + be_args.suffix = DEFAULT_SUFFIX + be_args.parent_suffix = None + be_args.create_entries = False backend_create(topology.standalone, None, topology.logcap.log, be_args) # And add the skeleton objects. @@ -38,6 +41,7 @@ def test_user_tasks(topology): topology.logcap.flush() u_args = FakeArgs() u_args.selector = 'testuser' + u_args.json = False with pytest.raises(ldap.NO_SUCH_OBJECT): get(topology.standalone, DEFAULT_SUFFIX, topology.logcap.log, u_args)