diff --git a/doc/source/cli/osc/v2/index.rst b/doc/source/cli/osc/v2/index.rst index 501fc3524..534208594 100644 --- a/doc/source/cli/osc/v2/index.rst +++ b/doc/source/cli/osc/v2/index.rst @@ -214,3 +214,10 @@ share servers .. autoprogram-cliff:: openstack.share.v2 :command: share server * + +============== +resource locks +============== + +.. autoprogram-cliff:: openstack.share.v2 + :command: share lock * diff --git a/manilaclient/api_versions.py b/manilaclient/api_versions.py index 639501625..c3cbd2bd2 100644 --- a/manilaclient/api_versions.py +++ b/manilaclient/api_versions.py @@ -27,7 +27,7 @@ from manilaclient import utils LOG = logging.getLogger(__name__) -MAX_VERSION = '2.79' +MAX_VERSION = '2.81' MIN_VERSION = '2.0' DEPRECATED_VERSION = '1.0' _VERSIONED_METHOD_MAP = {} diff --git a/manilaclient/common/constants.py b/manilaclient/common/constants.py index 4c1b38528..275e538b2 100644 --- a/manilaclient/common/constants.py +++ b/manilaclient/common/constants.py @@ -79,6 +79,16 @@ SHARE_TRANSFER_SORT_KEY_VALUES = ( 'expires_at', ) +RESOURCE_LOCK_SORT_KEY_VALUES = ( + 'id', + 'created_at', + 'updated_at', + 'resource_id', + 'resource_type', + 'resource_action' + 'lock_reason', +) + TASK_STATE_MIGRATION_SUCCESS = 'migration_success' TASK_STATE_MIGRATION_ERROR = 'migration_error' TASK_STATE_MIGRATION_CANCELLED = 'migration_cancelled' @@ -131,3 +141,4 @@ GROUP_BOOL_SPECS = ( REPLICA_GRADUATION_VERSION = '2.56' REPLICA_PRE_GRADUATION_VERSION = '2.55' SHARE_TRANSFER_VERSION = '2.77' +RESOURCE_LOCK_VERSION = '2.81' diff --git a/manilaclient/osc/v2/resource_locks.py b/manilaclient/osc/v2/resource_locks.py new file mode 100644 index 000000000..6199631bf --- /dev/null +++ b/manilaclient/osc/v2/resource_locks.py @@ -0,0 +1,411 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from openstackclient.identity import common as identity_common +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils as osc_utils +from oslo_utils import uuidutils + +from manilaclient.common._i18n import _ +from manilaclient.common.apiclient import utils as apiutils +from manilaclient.common import constants + + +LOG = logging.getLogger(__name__) + + +LOCK_DETAIL_ATTRIBUTES = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', + 'Lock Context', + 'User Id', + 'Project Id', + 'Created At', + 'Updated At', + 'Lock Reason', +] + +LOCK_SUMMARY_ATTRIBUTES = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', +] + +RESOURCE_TYPE_MANAGERS = { + 'share': 'shares', +} + + +class CreateResourceLock(command.ShowOne): + """Create a new resource lock.""" + _description = _("Lock a resource action from occurring on a resource") + + def get_parser(self, prog_name): + parser = super(CreateResourceLock, self).get_parser(prog_name) + parser.add_argument( + 'resource', + metavar='', + help='Name or ID of resource to lock.') + parser.add_argument( + 'resource_type', + metavar='', + help='Type of the resource (e.g.: share, access).') + parser.add_argument( + '--resource-action', + '--resource_action', + metavar='', + default='delete', + help='Action to lock on the resource (default="delete")') + parser.add_argument( + '--lock-reason', + '--lock_reason', + '--reason', + metavar='', + help='Reason for the resource lock.') + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + resource_type = parsed_args.resource_type + if resource_type not in RESOURCE_TYPE_MANAGERS: + raise exceptions.CommandError(_("Unsupported resource type")) + res_manager = RESOURCE_TYPE_MANAGERS[resource_type] + + resource = osc_utils.find_resource(getattr(share_client, res_manager), + parsed_args.resource) + resource_lock = share_client.resource_locks.create( + resource.id, + resource_type, + parsed_args.resource_action, + parsed_args.lock_reason + ) + + resource_lock._info.pop('links', None) + + return self.dict2columns(resource_lock._info) + + +class DeleteResourceLock(command.Command): + """Remove one or more resource locks.""" + _description = _("Remove one or more resource locks") + + def get_parser(self, prog_name): + parser = super(DeleteResourceLock, self).get_parser(prog_name) + parser.add_argument( + 'lock', + metavar='', + nargs='+', + help='ID(s) of the lock(s).') + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + failure_count = 0 + + for lock in parsed_args.lock: + try: + lock = apiutils.find_resource( + share_client.resource_locks, + lock + ) + lock.delete() + except Exception as e: + failure_count += 1 + LOG.error(_( + "Failed to delete %(lock)s: %(e)s"), + {'lock': lock, 'e': e}) + + if failure_count > 0: + raise exceptions.CommandError(_( + "Unable to delete some or all of the specified locks.")) + + +class ListResourceLock(command.Lister): + """Lists all resource locks.""" + _description = _("Lists all resource locks") + + def get_parser(self, prog_name): + parser = super(ListResourceLock, self).get_parser(prog_name) + parser.add_argument( + '--all-projects', + action='store_true', + help=_("Filter resource locks for all projects. (Admin only).") + ) + parser.add_argument( + '--project', + default=None, + help=_("Filter resource locks for specific project by name or ID, " + "combine with --all-projects (Admin only).") + ) + parser.add_argument( + '--user', + default=None, + help=_("Filter resource locks for specific user by name or ID, " + "combine with --all-projects to search across projects " + "(Admin only).") + ) + parser.add_argument( + '--id', + metavar='', + default=None, + help='Filter resource locks by ID. Default=None.') + parser.add_argument( + '--resource', + '--resource-id', + '--resource_id', + default=None, + metavar='', + dest='resource', + help=_("Filter resource locks for a resource by ID, specify " + "--resource-type to look up by name.") + ) + parser.add_argument( + '--resource-type', + '--resource_type', + default=None, + metavar='', + help=_("Filter resource locks by type of resource.") + ) + parser.add_argument( + '--resource-action', + '--resource_action', + default=None, + metavar='', + help=_("Filter resource locks by resource action.") + ) + + parser.add_argument( + '--lock-context', + '--lock_context', + '--context', + default=None, + choices=['user', 'admin', 'service'], + metavar='', + help=_("Filter resource locks by context.") + ) + parser.add_argument( + '--since', + default=None, + metavar='', + help=_("Filter resource locks created since given date. " + "The date format must be conforming to ISO8601. ") + ) + parser.add_argument( + '--before', + default=None, + metavar='', + help=_("Filter resource locks created before given date. " + "The date format must be conforming to ISO8601. ") + ) + parser.add_argument( + '--limit', + metavar='', + type=int, + default=None, + help=_("Number of resource locks to list. (Default=None)")) + parser.add_argument( + '--offset', + metavar="", + default=None, + help='Starting position of resource lock records ' + 'in a paginated list.') + parser.add_argument( + '--sort-key', '--sort_key', + metavar='', + type=str, + default=None, + choices=constants.RESOURCE_LOCK_SORT_KEY_VALUES, + help='Key to be sorted, available keys are %(keys)s. ' + 'Default=None.' + % {'keys': constants.RESOURCE_LOCK_SORT_KEY_VALUES}) + parser.add_argument( + '--sort-dir', '--sort_dir', + metavar='', + type=str, + default=None, + choices=constants.SORT_DIR_VALUES, + help='Sort direction, available values are %(values)s. ' + 'OPTIONAL: Default=None.' % { + 'values': constants.SORT_DIR_VALUES}) + parser.add_argument( + '--detailed', + dest='detailed', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help="Show detailed information about filtered resource locks.") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + columns = ( + LOCK_SUMMARY_ATTRIBUTES + if not parsed_args.detailed + else LOCK_DETAIL_ATTRIBUTES + ) + + project_id = None + user_id = None + + if parsed_args.project: + project_id = identity_common.find_project( + identity_client, + parsed_args.project, + parsed_args.project_domain).id + if parsed_args.user: + user_id = identity_common.find_user(identity_client, + parsed_args.user, + parsed_args.user_domain).id + # set all_projects when using project option + all_projects = bool(parsed_args.project) or parsed_args.all_projects + + resource_id = parsed_args.resource + resource_type = parsed_args.resource_type + if resource_type is not None: + if resource_type not in RESOURCE_TYPE_MANAGERS: + raise exceptions.CommandError(_("Unsupported resource type")) + if resource_id is not None: + res_manager = RESOURCE_TYPE_MANAGERS[resource_type] + resource_id = osc_utils.find_resource( + getattr(share_client, res_manager), + parsed_args.resource + ).id + elif resource_id and not uuidutils.is_uuid_like(resource_id): + raise exceptions.CommandError( + _("Provide resource ID or specify --resource-type.")) + + search_opts = { + 'all_projects': all_projects, + 'project_id': project_id, + 'user_id': user_id, + 'id': parsed_args.id, + 'resource_id': resource_id, + 'resource_type': parsed_args.resource_type, + 'resource_action': parsed_args.resource_action, + 'lock_context': parsed_args.lock_context, + 'created_before': parsed_args.before, + 'created_since': parsed_args.since, + 'limit': parsed_args.limit, + 'offset': parsed_args.offset, + } + + resource_locks = share_client.resource_locks.list( + search_opts=search_opts, + sort_key=parsed_args.sort_key, + sort_dir=parsed_args.sort_dir + ) + + return (columns, (osc_utils.get_item_properties + (m, columns) for m in resource_locks)) + + +class ShowResourceLock(command.ShowOne): + """Show details about a resource lock.""" + _description = _("Show details about a resource lock") + + def get_parser(self, prog_name): + parser = super(ShowResourceLock, self).get_parser(prog_name) + parser.add_argument( + 'lock', + metavar='', + help=_('ID of resource lock to show.')) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + resource_lock = apiutils.find_resource( + share_client.resource_locks, + parsed_args.lock) + + return ( + LOCK_DETAIL_ATTRIBUTES, + osc_utils.get_dict_properties(resource_lock._info, + LOCK_DETAIL_ATTRIBUTES) + ) + + +class SetResourceLock(command.Command): + """Set resource lock properties.""" + _description = _("Update resource lock properties") + + def get_parser(self, prog_name): + parser = super(SetResourceLock, self).get_parser(prog_name) + parser.add_argument( + 'lock', + metavar='', + help='ID of lock to update.') + parser.add_argument( + '--resource-action', + '--resource_action', + metavar='', + help='Resource action to set in the resource lock') + parser.add_argument( + '--lock-reason', + '--lock_reason', + '--reason', + dest='lock_reason', + help="Reason for the resource lock") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + update_kwargs = {} + if parsed_args.resource_action is not None: + update_kwargs['resource_action'] = parsed_args.resource_action + if parsed_args.lock_reason is not None: + update_kwargs['lock_reason'] = parsed_args.lock_reason + if update_kwargs: + share_client.resource_locks.update( + parsed_args.lock, + **update_kwargs + ) + + +class UnsetResourceLock(command.Command): + """Unsets a property on a resource lock.""" + _description = _("Remove resource lock properties") + + def get_parser(self, prog_name): + parser = super(UnsetResourceLock, self).get_parser(prog_name) + parser.add_argument( + 'lock', + metavar='', + help='ID of resource lock to update.') + parser.add_argument( + '--lock-reason', + '--lock_reason', + '--reason', + dest='lock_reason', + action='store_true', + default=False, + help="Unset the lock reason. (Default=False)") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + if parsed_args.lock_reason: + share_client.resource_locks.update( + parsed_args.lock, + lock_reason=None + ) diff --git a/manilaclient/tests/functional/osc/base.py b/manilaclient/tests/functional/osc/base.py index 76ed02a50..8d1c6a0ca 100644 --- a/manilaclient/tests/functional/osc/base.py +++ b/manilaclient/tests/functional/osc/base.py @@ -416,3 +416,21 @@ class OSCClientTestBase(base.ClientTestBase): check_result = self.dict_result('share', cmd) return check_result + + def create_resource_lock(self, resource_id, resource_type='share', + resource_action='delete', lock_reason=None, + add_cleanup=True, client=None): + + cmd = f'lock create {resource_id} {resource_type}' + cmd += f' --resource-action {resource_action}' + + if lock_reason: + cmd += f' --reason "{lock_reason}"' + + lock = self.dict_result('share', cmd, client=client) + + if add_cleanup: + self.addCleanup(self.openstack, + 'share lock delete %s' % lock['id'], + client=client) + return lock diff --git a/manilaclient/tests/functional/osc/test_resource_locks.py b/manilaclient/tests/functional/osc/test_resource_locks.py new file mode 100644 index 000000000..e51724c77 --- /dev/null +++ b/manilaclient/tests/functional/osc/test_resource_locks.py @@ -0,0 +1,167 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from tempest.lib.common.utils import data_utils +from tempest.lib import exceptions as lib_exc + +from manilaclient.tests.functional.osc import base +from manilaclient.tests.functional import utils + +LOCK_DETAIL_ATTRIBUTES = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', + 'Lock Context', + 'User Id', + 'Project Id', + 'Created At', + 'Updated At', + 'Lock Reason', +] + +LOCK_SUMMARY_ATTRIBUTES = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', +] + + +@utils.skip_if_microversion_not_supported('2.81') +class ResourceLockTests(base.OSCClientTestBase): + """Lock CLI test cases""" + + def setUp(self): + super(ResourceLockTests, self).setUp() + self.share_type = self.create_share_type( + name=data_utils.rand_name('lock_tests_type')) + self.share = self.create_share(share_type=self.share_type['id']) + + def test_lock_create_show_use_delete(self): + """Create a deletion lock on share, view it, try it and remove.""" + lock = self.create_resource_lock(self.share['id'], + lock_reason='tigers rule', + client=self.user_client, + add_cleanup=False) + + client_user_id = self.openstack( + "token issue -c user_id -f value", + client=self.user_client + ).strip() + client_project_id = self.openstack( + "token issue -c project_id -f value", + client=self.user_client + ).strip() + + self.assertEqual(self.share['id'], lock['resource_id']) + self.assertEqual('delete', lock['resource_action']) + self.assertEqual(client_user_id, lock['user_id']) + self.assertEqual(client_project_id, lock['project_id']) + self.assertEqual('user', lock['lock_context']) + self.assertEqual('tigers rule', lock['lock_reason']) + + lock_show = self.dict_result("share", f"lock show {lock['id']}") + self.assertEqual(lock['id'], lock_show['ID']) + self.assertEqual(lock['lock_context'], lock_show['Lock Context']) + + # When a deletion lock exists, share deletion must fail + self.assertRaises(lib_exc.CommandFailed, + self.openstack, + f"share delete {self.share['id']}") + + # delete the lock, share will be deleted in cleanup stage + self.openstack(f"share lock delete {lock['id']}", + client=self.user_client) + + self.assertRaises(lib_exc.CommandFailed, + self.openstack, + f"share lock show {lock['id']}") + + def test_lock_list_filter_paginate(self): + lock_1 = self.create_resource_lock(self.share['id'], + lock_reason='tigers rule', + client=self.user_client) + lock_2 = self.create_resource_lock(self.share['id'], + lock_reason='tigers still rule', + client=self.user_client) + lock_3 = self.create_resource_lock(self.share['id'], + lock_reason='admins rule', + client=self.admin_client) + + locks = self.listing_result('share', + f'lock list --resource {self.share["id"]}') + + self.assertEqual(3, len(locks)) + self.assertEqual(sorted(LOCK_SUMMARY_ATTRIBUTES), + sorted(locks[0].keys())) + + locks = self.listing_result('share', + 'lock list --lock-context user ' + f' --resource {self.share["id"]}') + self.assertEqual(2, len(locks)) + self.assertNotIn(lock_3['id'], [l['ID'] for l in locks]) + + locks = self.listing_result('share', + 'lock list --lock-context user' + f' --resource {self.share["id"]}' + ' --sort-key created_at ' + ' --sort-dir desc ' + ' --limit 1') + self.assertEqual(1, len(locks)) + self.assertIn(lock_2['id'], [l['ID'] for l in locks]) + self.assertNotIn(lock_1['id'], [l['ID'] for l in locks]) + self.assertNotIn(lock_3['id'], [l['ID'] for l in locks]) + + def test_lock_set_unset_lock_reason(self): + lock = self.create_resource_lock(self.share['id'], + client=self.user_client) + self.assertEqual('None', lock['lock_reason']) + + self.openstack('share lock set ' + f"--lock-reason 'updated reason' {lock['id']}") + lock_show = self.dict_result("share", f"lock show {lock['id']}") + self.assertEqual('updated reason', lock_show['Lock Reason']) + + self.openstack(f"share lock unset --lock-reason {lock['id']}") + lock_show = self.dict_result("share", f"lock show {lock['id']}") + self.assertEqual('None', lock_show['Lock Reason']) + + def test_lock_restrictions(self): + """A user can't update or delete a lock created by another user.""" + lock = self.create_resource_lock(self.share['id'], + client=self.admin_client, + add_cleanup=False) + self.assertEqual('admin', lock['lock_context']) + + self.assertRaises(lib_exc.CommandFailed, + self.openstack, + f"share lock set {lock['id']} " + f"--reason 'i cannot do this'", + client=self.user_client) + self.assertRaises(lib_exc.CommandFailed, + self.openstack, + f"share lock unset {lock['id']} --reason", + client=self.user_client) + self.assertRaises(lib_exc.CommandFailed, + self.openstack, + f"share lock delete {lock['id']} ", + client=self.user_client) + + self.openstack(f'share lock set ' + f'--lock-reason "I can do this" ' + f'{lock["id"]}', + client=self.admin_client) + + self.openstack(f'share lock delete ' + f'{lock["id"]}', + client=self.admin_client) diff --git a/manilaclient/tests/unit/osc/v2/fakes.py b/manilaclient/tests/unit/osc/v2/fakes.py index 967105fcc..79a583b04 100644 --- a/manilaclient/tests/unit/osc/v2/fakes.py +++ b/manilaclient/tests/unit/osc/v2/fakes.py @@ -61,6 +61,7 @@ class FakeShareClient(object): self.share_group_types = mock.Mock() self.share_group_type_access = mock.Mock() self.share_servers = mock.Mock() + self.resource_locks = mock.Mock() class ManilaParseException(Exception): @@ -1521,3 +1522,61 @@ class FakeShareServer(object): share_servers.append( FakeShareServer.create_one_server(attrs)) return share_servers + + +class FakeResourceLock(object): + """Fake a resource lock""" + + @staticmethod + def create_one_lock(attrs=None, methods=None): + """Create a fake resource lock + + :param Dictionary attrs: + A dictionary with all attributes + :return: + A FakeResource object, with id, resource and so on + """ + + attrs = attrs or {} + methods = methods or {} + now_time = datetime.datetime.now() + delta_time = now_time + datetime.timedelta(minutes=5) + + lock = { + 'id': str(uuid.uuid4()), + 'resource_id': str(uuid.uuid4()), + 'resource_type': 'share', + 'resource_action': 'delete', + 'created_at': now_time.isoformat(), + 'updated_at': delta_time.isoformat(), + 'project_id': uuid.uuid4().hex, + 'user_id': uuid.uuid4().hex, + 'lock_context': 'user', + 'lock_reason': 'created by func tests', + } + + lock.update(attrs) + lock = osc_fakes.FakeResource(info=copy.deepcopy( + lock), + methods=methods, + loaded=True) + return lock + + @staticmethod + def create_locks(attrs=None, count=2): + """Create multiple fake locks. + + :param Dictionary attrs: + A dictionary with all attributes + :param Integer count: + The number of share transfers to be faked + :return: + A list of FakeResource objects + """ + + resource_locks = [] + for n in range(0, count): + resource_locks.append( + FakeResourceLock.create_one_lock(attrs)) + + return resource_locks diff --git a/manilaclient/tests/unit/osc/v2/test_resource_locks.py b/manilaclient/tests/unit/osc/v2/test_resource_locks.py new file mode 100644 index 000000000..6c5ca06f5 --- /dev/null +++ b/manilaclient/tests/unit/osc/v2/test_resource_locks.py @@ -0,0 +1,342 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +from osc_lib import exceptions +from osc_lib import utils as oscutils + +from manilaclient import api_versions +from manilaclient.osc.v2 import resource_locks as osc_resource_locks +from manilaclient.tests.unit.osc import osc_utils +from manilaclient.tests.unit.osc.v2 import fakes as manila_fakes + +DETAIL_COLUMNS = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', + 'Created At', + 'Updated At', + 'User Id', + 'Project Id', + 'Lock Reason', + 'Lock Context', +] + +SUMMARY_COLUMNS = [ + 'ID', + 'Resource Id', + 'Resource Type', + 'Resource Action', +] + + +class TestResourceLock(manila_fakes.TestShare): + + def setUp(self): + super(TestResourceLock, self).setUp() + + self.shares_mock = self.app.client_manager.share.shares + self.shares_mock.reset_mock() + + self.locks_mock = self.app.client_manager.share.resource_locks + self.locks_mock.reset_mock() + + self.app.client_manager.share.api_version = api_versions.APIVersion( + api_versions.MAX_VERSION) + + +class TestResourceLockCreate(TestResourceLock): + + def setUp(self): + super(TestResourceLockCreate, self).setUp() + + self.share = manila_fakes.FakeShare.create_one_share() + self.shares_mock.create.return_value = self.share + + self.shares_mock.get.return_value = self.share + + self.lock = manila_fakes.FakeResourceLock.create_one_lock( + attrs={'resource_id': self.share.id}) + self.locks_mock.get.return_value = self.lock + self.locks_mock.create.return_value = self.lock + + self.cmd = osc_resource_locks.CreateResourceLock(self.app, None) + + self.data = tuple(self.lock._info.values()) + self.columns = tuple(self.lock._info.keys()) + + def test_share_lock_create_missing_required_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_lock_create(self): + arglist = [ + '--resource-action', 'revert_to_snapshot', + '--lock-reason', "you cannot go back in time", + self.share.id, + 'share', + ] + verifylist = [ + ('resource', self.share.id), + ('resource_type', 'share'), + ('resource_action', 'revert_to_snapshot'), + ('lock_reason', 'you cannot go back in time') + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.locks_mock.create.assert_called_with( + self.share.id, + 'share', + 'revert_to_snapshot', + 'you cannot go back in time', + ) + + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + +class TestResourceLockDelete(TestResourceLock): + + def setUp(self): + super(TestResourceLockDelete, self).setUp() + + self.lock = manila_fakes.FakeResourceLock.create_one_lock() + + self.locks_mock.get.return_value = self.lock + self.lock.delete = mock.Mock() + + self.cmd = osc_resource_locks.DeleteResourceLock(self.app, None) + + def test_share_lock_delete_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_lock_delete(self): + arglist = [ + self.lock.id + ] + verifylist = [ + ('lock', [self.lock.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.lock.delete.assert_called_once_with() + self.assertIsNone(result) + + def test_share_lock_delete_multiple(self): + locks = manila_fakes.FakeResourceLock.create_locks(count=2) + arglist = [ + locks[0].id, + locks[1].id + ] + verifylist = [ + ('lock', [locks[0].id, locks[1].id]) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.assertEqual(self.lock.delete.call_count, + len(locks)) + self.assertIsNone(result) + + def test_share_lock_delete_exception(self): + arglist = [ + self.lock.id + ] + verifylist = [ + ('lock', [self.lock.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.lock.delete.side_effect = exceptions.CommandError() + self.assertRaises(exceptions.CommandError, + self.cmd.take_action, + parsed_args) + + +class TestResourceLockShow(TestResourceLock): + + def setUp(self): + super(TestResourceLockShow, self).setUp() + + self.lock = manila_fakes.FakeResourceLock.create_one_lock() + self.locks_mock.get.return_value = self.lock + + self.cmd = osc_resource_locks.ShowResourceLock(self.app, None) + + self.data = self.lock._info.values() + self.columns = list(self.lock._info.keys()) + + def test_share_lock_show_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_lock_show(self): + arglist = [ + self.lock.id, + ] + verifylist = [ + ('lock', self.lock.id) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.locks_mock.get.assert_called_with(self.lock.id) + self.assertEqual(len(self.columns), len(columns)) + self.assertCountEqual(sorted(self.data), sorted(data)) + + +class TestResourceLockList(TestResourceLock): + + def setUp(self): + super(TestResourceLockList, self).setUp() + + self.locks = manila_fakes.FakeResourceLock.create_locks(count=2) + + self.locks_mock.list.return_value = self.locks + + self.values = (oscutils.get_dict_properties( + m._info, DETAIL_COLUMNS) for m in self.locks) + + self.cmd = osc_resource_locks.ListResourceLock(self.app, None) + + def test_share_lock_list(self): + arglist = [ + '--detailed' + ] + verifylist = [ + ('detailed', True) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.locks_mock.list.assert_called_with( + search_opts={ + 'all_projects': False, + 'project_id': None, + 'user_id': None, + 'id': None, + 'resource_id': None, + 'resource_type': None, + 'resource_action': None, + 'lock_context': None, + 'created_before': None, + 'created_since': None, + 'limit': None, + 'offset': None, + }, + sort_key=None, + sort_dir=None + ) + + self.assertEqual(sorted(DETAIL_COLUMNS), sorted(columns)) + actual_data = [sorted(d) for d in data] + expected_data = [sorted(v) for v in self.values] + self.assertEqual(actual_data, expected_data) + + +class TestResourceLockSet(TestResourceLock): + + def setUp(self): + super(TestResourceLockSet, self).setUp() + + self.lock = manila_fakes.FakeResourceLock.create_one_lock() + self.lock.update = mock.Mock() + + self.locks_mock.get.return_value = self.lock + + self.cmd = osc_resource_locks.SetResourceLock(self.app, None) + + def test_share_lock_set_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_lock_set(self): + arglist = [ + self.lock.id, + '--resource-action', 'unmanage', + ] + verifylist = [ + ('lock', self.lock.id), + ('resource_action', 'unmanage') + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.assertIsNone(result) + self.locks_mock.update.assert_called_with(self.lock.id, + resource_action='unmanage') + + +class TestResourceLockUnSet(TestResourceLock): + + def setUp(self): + super(TestResourceLockUnSet, self).setUp() + + self.lock = manila_fakes.FakeResourceLock.create_one_lock() + self.lock.update = mock.Mock() + + self.locks_mock.get.return_value = self.lock + + self.cmd = osc_resource_locks.UnsetResourceLock(self.app, None) + + def test_share_lock_unset_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_lock_unset(self): + arglist = [ + self.lock.id, + '--lock-reason' + ] + verifylist = [ + ('lock', self.lock.id), + ('lock_reason', True) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.assertIsNone(result) + self.locks_mock.update.assert_called_with(self.lock.id, + lock_reason=None) diff --git a/manilaclient/v2/client.py b/manilaclient/v2/client.py index b9d061784..036ae6e4e 100644 --- a/manilaclient/v2/client.py +++ b/manilaclient/v2/client.py @@ -23,6 +23,7 @@ from manilaclient.v2 import limits from manilaclient.v2 import messages from manilaclient.v2 import quota_classes from manilaclient.v2 import quotas +from manilaclient.v2 import resource_locks from manilaclient.v2 import scheduler_stats from manilaclient.v2 import security_services from manilaclient.v2 import services @@ -201,6 +202,8 @@ class Client(object): self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) + self.resource_locks = resource_locks.ResourceLockManager(self) + self.shares = shares.ShareManager(self) self.share_export_locations = ( share_export_locations.ShareExportLocationManager(self)) diff --git a/manilaclient/v2/resource_locks.py b/manilaclient/v2/resource_locks.py new file mode 100644 index 000000000..b396420f5 --- /dev/null +++ b/manilaclient/v2/resource_locks.py @@ -0,0 +1,130 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from manilaclient import api_versions +from manilaclient import base +from manilaclient.common import constants + + +class ResourceLock(base.Resource): + """Lock a share resource action from being executed""" + + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this lock.""" + return self.manager.delete(self) + + def update(self, **kwargs): + """Update this lock.""" + return self.manager.update(self, **kwargs) + + +class ResourceLockManager(base.ManagerWithFind): + """Manage :class:`ResourceLock` resources.""" + resource_class = ResourceLock + + @api_versions.wraps(constants.RESOURCE_LOCK_VERSION) + def create(self, resource_id, resource_type, + resource_action='delete', lock_reason=None): + """Creates a resource lock. + + :param resource_id: The ID of the resource to lock + :param resource_type: The type of the resource (e.g., "share", + "access") + :param resource_action: The functionality to lock (e.g., "delete", + "view/delete") + :param lock_reason: Lock description + :rtype: :class:`ResourceLock` + """ + body = { + 'resource_lock': { + 'resource_id': resource_id, + 'resource_type': resource_type, + 'resource_action': resource_action, + 'lock_reason': lock_reason, + } + } + + return self._create('/resource-locks', body, 'resource_lock') + + @api_versions.wraps(constants.RESOURCE_LOCK_VERSION) + def get(self, lock_id): + """Show details of a resource lock. + + :param lock_id: The ID of the resource lock to display. + :rtype: :class:`ResourceLock` + """ + return self._get("/resource-locks/%s" % lock_id, "resource_lock") + + @api_versions.wraps(constants.RESOURCE_LOCK_VERSION) + def list(self, search_opts=None, sort_key=None, sort_dir=None): + """Get a list of all resource locks. + + :param search_opts: Filtering options as a dictionary. + :param sort_key: Key to be sorted (i.e. 'created_at'). + :param sort_dir: Sort direction, should be 'desc' or 'asc'. + :rtype: list of :class:`ResourceLock` + """ + search_opts = search_opts or {} + + sort_key = sort_key or 'created_at' + if sort_key in constants.RESOURCE_LOCK_SORT_KEY_VALUES: + search_opts['sort_key'] = sort_key + else: + raise ValueError( + 'sort_key must be one of the following: %s.' + % ', '.join(constants.RESOURCE_LOCK_SORT_KEY_VALUES)) + sort_dir = sort_dir or 'desc' + if sort_dir in constants.SORT_DIR_VALUES: + search_opts['sort_dir'] = sort_dir + else: + raise ValueError('sort_dir must be one of the following: %s.' + % ', '.join(constants.SORT_DIR_VALUES)) + + query_string = self._build_query_string(search_opts) + + path = "/resource-locks%s" % (query_string,) + + return self._list(path, 'resource_locks') + + @api_versions.wraps(constants.RESOURCE_LOCK_VERSION) + def update(self, lock, **kwargs): + """Updates a resource lock. + + :param lock: The :class:`ResourceLock` object, or a lock id to update. + :param kwargs: "resource_action" and "lock_reason" are allowed kwargs + :rtype: :class:`ResourceLock` + """ + if not kwargs: + return + + body = {'resource_lock': {}} + + if 'lock_reason' in kwargs: + body['resource_lock']['lock_reason'] = kwargs['lock_reason'] + if 'resource_action' in kwargs: + body['resource_lock']['resource_action'] = ( + kwargs['resource_action'] + ) + + lock_id = base.getid(lock) + return self._update("/resource-locks/%s" % lock_id, body) + + @api_versions.wraps(constants.RESOURCE_LOCK_VERSION) + def delete(self, lock): + """Delete a resource lock. + + :param lock: The :class:`ResourceLock` object, or a lock id to delete. + """ + return self._delete("/resource-locks/%s" % base.getid(lock)) diff --git a/releasenotes/notes/bp-allow-locking-shares-against-deletion-89e51e27368cda46.yaml b/releasenotes/notes/bp-allow-locking-shares-against-deletion-89e51e27368cda46.yaml new file mode 100644 index 000000000..12d909910 --- /dev/null +++ b/releasenotes/notes/bp-allow-locking-shares-against-deletion-89e51e27368cda46.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Added SDK and OSC commands to create, view, update and delete resource + locks, alongside support for API version 2.81. diff --git a/setup.cfg b/setup.cfg index fb55011d7..4a91b0f94 100644 --- a/setup.cfg +++ b/setup.cfg @@ -166,6 +166,12 @@ openstack.share.v2 = share_transfer_list = manilaclient.osc.v2.share_transfers:ListShareTransfer share_transfer_show = manilaclient.osc.v2.share_transfers:ShowShareTransfer share_transfer_accept = manilaclient.osc.v2.share_transfers:AcceptShareTransfer + share_lock_create = manilaclient.osc.v2.resource_locks:CreateResourceLock + share_lock_list = manilaclient.osc.v2.resource_locks:ListResourceLock + share_lock_show = manilaclient.osc.v2.resource_locks:ShowResourceLock + share_lock_set = manilaclient.osc.v2.resource_locks:SetResourceLock + share_lock_unset = manilaclient.osc.v2.resource_locks:UnsetResourceLock + share_lock_delete = manilaclient.osc.v2.resource_locks:DeleteResourceLock [coverage:run] omit = manilaclient/tests/*