diff --git a/manilaclient/api_versions.py b/manilaclient/api_versions.py index 98fbb61ad..a42206592 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.75' +MAX_VERSION = '2.77' MIN_VERSION = '2.0' DEPRECATED_VERSION = '1.0' _VERSIONED_METHOD_MAP = {} diff --git a/manilaclient/base.py b/manilaclient/base.py index 67e40f831..4166c58c1 100644 --- a/manilaclient/base.py +++ b/manilaclient/base.py @@ -193,6 +193,9 @@ class Manager(utils.HookableMixin): with self.completion_cache('uuid', self.resource_class, mode="a"): return self.resource_class(self, body[response_key]) + def _accept(self, url, body): + resp, body = self.api.client.post(url, body=body) + def _delete(self, url): resp, body = self.api.client.delete(url) diff --git a/manilaclient/common/constants.py b/manilaclient/common/constants.py index 634b83721..4c1b38528 100644 --- a/manilaclient/common/constants.py +++ b/manilaclient/common/constants.py @@ -68,6 +68,17 @@ SHARE_GROUP_SNAPSHOT_SORT_KEY_VALUES = ( 'share_group_id', ) +SHARE_TRANSFER_SORT_KEY_VALUES = ( + 'id', + 'resource_type', + 'resource_id', + 'name', + 'source_project_id', + 'destination_project_id', + 'created_at', + 'expires_at', +) + TASK_STATE_MIGRATION_SUCCESS = 'migration_success' TASK_STATE_MIGRATION_ERROR = 'migration_error' TASK_STATE_MIGRATION_CANCELLED = 'migration_cancelled' @@ -119,3 +130,4 @@ GROUP_BOOL_SPECS = ( REPLICA_GRADUATION_VERSION = '2.56' REPLICA_PRE_GRADUATION_VERSION = '2.55' +SHARE_TRANSFER_VERSION = '2.77' diff --git a/manilaclient/osc/v2/share_transfers.py b/manilaclient/osc/v2/share_transfers.py new file mode 100644 index 000000000..912a4b6ab --- /dev/null +++ b/manilaclient/osc/v2/share_transfers.py @@ -0,0 +1,272 @@ +# 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 osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils as osc_utils + +from manilaclient.common._i18n import _ +from manilaclient.common.apiclient import utils as apiutils +from manilaclient.common import constants + + +LOG = logging.getLogger(__name__) + +TRANSFER_DETAIL_ATTRIBUTES = [ + 'id', + 'created_at', + 'name', + 'resource_type', + 'resource_id', + 'source_project_id', + 'destination_project_id', + 'accepted', + 'expires_at' +] + +TRANSFER_SUMMARY_ATTRIBUTES = [ + 'id', + 'name', + 'resource_type', + 'resource_id', +] + + +class CreateShareTransfer(command.ShowOne): + """Create a new share transfer.""" + _description = _("Create a new share transfer") + + def get_parser(self, prog_name): + parser = super(CreateShareTransfer, self).get_parser(prog_name) + parser.add_argument( + 'share', + metavar='', + help='Name or ID of share to transfer.') + parser.add_argument( + '--name', + metavar='', + default=None, + help='Transfer name. Default=None.') + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + share = osc_utils.find_resource(share_client.shares, + parsed_args.share) + transfer = share_client.transfers.create( + share.id, name=parsed_args.name) + + transfer._info.pop('links', None) + + return self.dict2columns(transfer._info) + + +class DeleteShareTransfer(command.Command): + """Remove one or more transfers.""" + _description = _("Remove one or more transfers") + + def get_parser(self, prog_name): + parser = super(DeleteShareTransfer, self).get_parser(prog_name) + parser.add_argument( + 'transfer', + metavar='', + nargs='+', + help='Name(s) or ID(s) of the transfer(s).') + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + failure_count = 0 + + for transfer in parsed_args.transfer: + try: + transfer_obj = apiutils.find_resource( + share_client.transfers, + transfer) + share_client.transfers.delete(transfer_obj.id) + except Exception as e: + failure_count += 1 + LOG.error(_( + "Failed to delete %(transfer)s: %(e)s"), + {'transfer': transfer, 'e': e}) + + if failure_count > 0: + raise exceptions.CommandError(_( + "Unable to delete some or all of the specified transfers.")) + + +class ListShareTransfer(command.Lister): + """Lists all transfers.""" + _description = _("Lists all transfers") + + def get_parser(self, prog_name): + parser = super(ListShareTransfer, self).get_parser(prog_name) + parser.add_argument( + '--all-projects', + action='store_true', + help=_("Shows details for all tenants. (Admin only).") + ) + parser.add_argument( + '--name', + metavar='', + default=None, + help='Filter share transfers by name. Default=None.') + parser.add_argument( + '--id', + metavar='', + default=None, + help='Filter share transfers by ID. Default=None.') + parser.add_argument( + '--resource-type', '--resource_type', + metavar='', + default=None, + help='Filter share transfers by resource type, ' + 'which can be share. Default=None.') + parser.add_argument( + '--resource-id', '--resource_id', + metavar='', + default=None, + help='Filter share transfers by resource ID. Default=None.') + parser.add_argument( + '--source-project-id', '--source_project_id', + metavar='', + default=None, + help='Filter share transfers by ID of the Project that ' + 'initiated the transfer. Default=None.') + parser.add_argument( + '--limit', + metavar='', + type=int, + default=None, + help='Maximum number of transfer records to ' + 'return. (Default=None)') + parser.add_argument( + '--offset', + metavar="", + default=None, + help='Start position of transfer records listing.') + parser.add_argument( + '--sort-key', '--sort_key', + metavar='', + type=str, + default=None, + help='Key to be sorted, available keys are %(keys)s. ' + 'Default=None.' + % {'keys': constants.SHARE_TRANSFER_SORT_KEY_VALUES}) + parser.add_argument( + '--sort-dir', '--sort_dir', + metavar='', + type=str, + default=None, + 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 share transfers.") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + columns = [ + 'ID', + 'Name', + 'Resource Type', + 'Resource Id' + ] + + if parsed_args.detailed: + columns.extend(['Created At', 'Source Project Id', + 'Destination Project Id', 'Accepted', + 'Expires At']) + + search_opts = { + 'all_tenants': parsed_args.all_projects, + 'id': parsed_args.id, + 'name': parsed_args.name, + 'limit': parsed_args.limit, + 'offset': parsed_args.offset, + 'resource_type': parsed_args.resource_type, + 'resource_id': parsed_args.resource_id, + 'source_project_id': parsed_args.source_project_id, + } + + transfers = share_client.transfers.list( + detailed=parsed_args.detailed, 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 transfers)) + + +class ShowShareTransfer(command.ShowOne): + """Show details about a share transfer.""" + _description = _("Show details about a share transfer") + + def get_parser(self, prog_name): + parser = super(ShowShareTransfer, self).get_parser(prog_name) + parser.add_argument( + 'transfer', + metavar='', + help=_('Name or ID of transfer to show.')) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + transfer = apiutils.find_resource( + share_client.transfers, + parsed_args.transfer) + + return (TRANSFER_DETAIL_ATTRIBUTES, osc_utils.get_dict_properties( + transfer._info, TRANSFER_DETAIL_ATTRIBUTES)) + + +class AcceptShareTransfer(command.Command): + """Accepts a share transfer.""" + _description = _("Accepts a share transfer") + + def get_parser(self, prog_name): + parser = super(AcceptShareTransfer, self).get_parser(prog_name) + parser.add_argument( + 'transfer', + metavar='', + help='ID of transfer to accept.') + parser.add_argument( + 'auth_key', + metavar='', + help='Authentication key of transfer to accept.') + parser.add_argument( + '--clear-rules', + '--clear_rules', + dest='clear_rules', + action='store_true', + default=False, + help="Whether manila should clean up the access rules after the " + "transfer is complete. (Default=False)") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + share_client.transfers.accept( + parsed_args.transfer, parsed_args.auth_key, + clear_access_rules=parsed_args.clear_rules) diff --git a/manilaclient/tests/functional/base.py b/manilaclient/tests/functional/base.py index 9b1c39aca..f54565a1e 100644 --- a/manilaclient/tests/functional/base.py +++ b/manilaclient/tests/functional/base.py @@ -368,6 +368,36 @@ class BaseTestCase(base.ClientTestBase): client.restore_share(shares_to_restore, microversion=microversion) + @classmethod + def create_share_transfer(cls, share_id, name=None, + client=None, microversion=None): + client = client or cls.get_admin_client() + return client.create_share_transfer(share_id, name=name, + microversion=microversion) + + @classmethod + def delete_share_transfer(cls, transfer, client=None, + microversion=None): + client = client or cls.get_admin_client() + client.delete_share_transfer(transfer, microversion=microversion) + + @classmethod + def get_share_transfer(cls, transfer, client=None, microversion=None): + client = client or cls.get_admin_client() + return client.get_share_transfer(transfer, microversion=microversion) + + @classmethod + def list_share_transfer(cls, client=None, microversion=None): + client = client or cls.get_admin_client() + return client.list_share_transfer(microversion=microversion) + + @classmethod + def accept_share_transfer(cls, transfer, auth_key, + client=None, microversion=None): + client = client or cls.get_admin_client() + client.accept_share_transfer(transfer, auth_key, + microversion=microversion) + @classmethod def _determine_share_network_to_use(cls, client, share_type, microversion=None): diff --git a/manilaclient/tests/functional/client.py b/manilaclient/tests/functional/client.py index 360a4fdd0..344dc5f5e 100644 --- a/manilaclient/tests/functional/client.py +++ b/manilaclient/tests/functional/client.py @@ -37,6 +37,7 @@ SHARE_NETWORK_SUBNET = 'share_network_subnet' SHARE_SERVER = 'share_server' SNAPSHOT = 'snapshot' SHARE_REPLICA = 'share_replica' +TRANSFER = 'transfer' def not_found_wrapper(f): @@ -142,6 +143,8 @@ class ManilaCLIClient(base.CLIClient): func = self.is_message_deleted elif res_type == SHARE_REPLICA: func = self.is_share_replica_deleted + elif res_type == TRANSFER: + func = self.is_share_transfer_deleted else: raise exceptions.InvalidResource(message=res_type) @@ -919,6 +922,55 @@ class ManilaCLIClient(base.CLIClient): cmd += '%s ' % share return self.manila(cmd, microversion=microversion) + def create_share_transfer(self, share_id, name=None, + microversion=None): + """Create a share transfer. + + :param share_id: ID of share. + ":param name: name of transfer. + """ + cmd = 'share-transfer-create %s ' % share_id + if name: + cmd += '--name %s' % name + transfer_raw = self.manila(cmd, microversion=microversion) + transfer = output_parser.details(transfer_raw) + return transfer + + def delete_share_transfer(self, transfer, microversion=None): + """Delete a share transfer. + + :param transfer: ID or name of share transfer. + """ + cmd = 'share-transfer-delete %s ' % transfer + self.manila(cmd, microversion=microversion) + + def get_share_transfer(self, transfer, microversion=None): + """Get a share transfer. + + :param transfer: ID or name of share transfer. + """ + cmd = 'share-transfer-show %s ' % transfer + transfer_raw = self.manila(cmd, microversion=microversion) + transfer = output_parser.details(transfer_raw) + return transfer + + def list_share_transfer(self, microversion=None): + """Get a share transfer.""" + + cmd = 'share-transfer-list ' + transfer_raw = self.manila(cmd, microversion=microversion) + transfers = utils.listing(transfer_raw) + return transfers + + def accept_share_transfer(self, transfer, auth_key, + microversion=None): + """Accept a share transfer. + + :param transfer: ID or name of share transfer. + """ + cmd = 'share-transfer-accept %s %s' % (transfer, auth_key) + self.manila(cmd, microversion=microversion) + def list_shares(self, all_tenants=False, is_soft_deleted=False, filters=None, columns=None, is_public=False, microversion=None): @@ -1003,6 +1055,26 @@ class ManilaCLIClient(base.CLIClient): SHARE, res_id=share, interval=5, timeout=300, microversion=microversion) + def is_share_transfer_deleted(self, transfer, microversion=None): + """Says whether transfer is deleted or not. + + :param transfer: str -- Name or ID of transfer + """ + try: + self.get_transfer(transfer, microversion=microversion) + return False + except tempest_lib_exc.NotFound: + return True + + def wait_for_transfer_deletion(self, transfer, microversion=None): + """Wait for transfer deletion by its Name or ID. + + :param transfer: str -- Name or ID of transfer. + """ + self.wait_for_resource_deletion( + SHARE, res_id=transfer, interval=5, timeout=300, + microversion=microversion) + def wait_for_share_soft_deletion(self, share_id, microversion=None): body = self.get_share(share_id, microversion=microversion) is_soft_deleted = body['is_soft_deleted'] diff --git a/manilaclient/tests/functional/osc/base.py b/manilaclient/tests/functional/osc/base.py index f14f58a73..76ed02a50 100644 --- a/manilaclient/tests/functional/osc/base.py +++ b/manilaclient/tests/functional/osc/base.py @@ -302,6 +302,14 @@ class OSCClientTestBase(base.ClientTestBase): return snapshot_object + def create_share_transfer(self, share, name=None, client=None): + + name = name or data_utils.rand_name('autotest_share_transfer_name') + cmd = (f'transfer create {share} --name {name} ') + transfer_object = self.dict_result('share', cmd, client=client) + + return transfer_object + def create_share_network(self, neutron_net_id=None, neutron_subnet_id=None, name=None, description=None, availability_zone=None, diff --git a/manilaclient/tests/functional/osc/test_share_transfers.py b/manilaclient/tests/functional/osc/test_share_transfers.py new file mode 100644 index 000000000..41bfb0e34 --- /dev/null +++ b/manilaclient/tests/functional/osc/test_share_transfers.py @@ -0,0 +1,85 @@ +# Copyright (c) 2022 China Telecom Digital Intelligence. +# All Rights Reserved. +# +# 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.tests.functional.osc import base + + +class TransfersCLITest(base.OSCClientTestBase): + + def setUp(self): + super(TransfersCLITest, self).setUp() + self.share_type = self.create_share_type() + + def test_transfer_create_list_show_delete(self): + share = self.create_share(share_type=self.share_type['name'], + wait_for_status='available', + client=self.user_client) + # create share transfer + self.create_share_transfer(share['id'], name='transfer_test') + self._wait_for_object_status('share', share['id'], 'awaiting_transfer') + + # Get all transfers + transfers = self.listing_result( + 'share', 'transfer list', client=self.user_client) + # We must have at least one transfer + self.assertTrue(len(transfers) > 0) + self.assertTableStruct(transfers, [ + 'ID', + 'Name', + 'Resource Type', + 'Resource Id', + ]) + + # grab the transfer we created + transfer = [transfer for transfer in transfers + if transfer['Resource Id'] == share['id']] + self.assertEqual(1, len(transfer)) + + show_transfer = self.dict_result('share', + f'transfer show {transfer[0]["ID"]}') + self.assertEqual(transfer[0]['ID'], show_transfer['id']) + expected_keys = ( + 'id', 'created_at', 'name', 'resource_type', 'resource_id', + 'source_project_id', 'destination_project_id', 'accepted', + 'expires_at', + ) + for key in expected_keys: + self.assertIn(key, show_transfer) + + # filtering by Resource ID + filtered_transfers = self.listing_result( + 'share', + f'transfer list --resource-id {share["id"]}', + client=self.user_client) + self.assertEqual(1, len(filtered_transfers)) + self.assertEqual(show_transfer['resource_id'], share["id"]) + + # finally delete transfer and share + self.openstack(f'share transfer delete {show_transfer["id"]}') + self._wait_for_object_status('share', share['id'], 'available') + + def test_transfer_accept(self): + share = self.create_share(share_type=self.share_type['name'], + wait_for_status='available', + client=self.user_client) + # create share transfer + transfer = self.create_share_transfer(share['id'], + name='transfer_test') + self._wait_for_object_status('share', share['id'], 'awaiting_transfer') + + # accept share transfer + self.openstack( + f'share transfer accept {transfer["id"]} {transfer["auth_key"]}') + self._wait_for_object_status('share', share['id'], 'available') diff --git a/manilaclient/tests/functional/test_share_transfers.py b/manilaclient/tests/functional/test_share_transfers.py new file mode 100644 index 000000000..846213980 --- /dev/null +++ b/manilaclient/tests/functional/test_share_transfers.py @@ -0,0 +1,95 @@ +# Copyright (c) 2022 China Telecom Digital Intelligence. +# All Rights Reserved. +# +# 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 manilaclient.tests.functional import base + + +class ShareTransferTests(base.BaseTestCase): + """Check of base share transfers command""" + + def setUp(self): + super(ShareTransferTests, self).setUp() + self.share_type = self.create_share_type( + name=data_utils.rand_name('test_share_type'), + driver_handles_share_servers=False) + + def test_transfer_create_list_show_delete(self): + """Create, list, show and delete a share transfer""" + self.skip_if_microversion_not_supported('2.77') + share = self.create_share( + share_protocol='nfs', + size=1, + name=data_utils.rand_name('autotest_share_name'), + client=self.user_client, + share_type=self.share_type['ID'], + use_wait_option=True) + self.assertEqual("available", share['status']) + # create share transfer + transfer = self.create_share_transfer(share['id'], + name='test_share_transfer') + self.assertIn('auth_key', transfer) + + # list share transfers + transfers = self.list_share_transfer() + # We must have at least one transfer + self.assertTrue(len(transfers) > 0) + + # show the share transfer + transfer_show = self.get_share_transfer(transfer['id']) + self.assertEqual(transfer_show['name'], transfer['name']) + self.assertNotIn('auth_key', transfer_show) + + # delete share transfer + self.delete_share_transfer(transfer['id']) + self.user_client.wait_for_transfer_deletion(transfer['id']) + share = self.user_client.get_share(share['id']) + self.assertEqual("available", share['status']) + # finally delete the share + self.user_client.delete_share(share['id']) + self.user_client.wait_for_share_deletion(share['id']) + + def test_transfer_accept(self): + """Show share transfer accept""" + self.skip_if_microversion_not_supported('2.77') + share = self.create_share( + share_protocol='nfs', + size=1, + name=data_utils.rand_name('autotest_share_name'), + client=self.user_client, + share_type=self.share_type['ID'], + use_wait_option=True) + self.assertEqual("available", share['status']) + # create share transfer + transfer = self.create_share_transfer(share['id'], + name='test_share_transfer') + share = self.user_client.get_share(share['id']) + transfer_id = transfer['id'] + auth_key = transfer['auth_key'] + self.assertEqual("share", transfer['resource_type']) + self.assertEqual('test_share_transfer', transfer['name']) + self.assertEqual("awaiting_transfer", share['status']) + + # accept the share transfer + self.accept_share_transfer(transfer_id, auth_key) + # after accept complete, the transfer will be deleted. + self.user_client.wait_for_transfer_deletion(transfer_id) + share = self.user_client.get_share(share['id']) + # check share status roll back to available + self.assertEqual("available", share['status']) + # finally delete the share + self.user_client.delete_share(share['id']) + self.user_client.wait_for_share_deletion(share['id']) diff --git a/manilaclient/tests/unit/osc/v2/fakes.py b/manilaclient/tests/unit/osc/v2/fakes.py index b4807f227..df67c4a4f 100644 --- a/manilaclient/tests/unit/osc/v2/fakes.py +++ b/manilaclient/tests/unit/osc/v2/fakes.py @@ -30,6 +30,7 @@ class FakeShareClient(object): self.auth_token = kwargs['token'] self.management_url = kwargs['endpoint'] self.shares = mock.Mock() + self.transfers = mock.Mock() self.share_access_rules = mock.Mock() self.share_groups = mock.Mock() self.share_types = mock.Mock() @@ -585,6 +586,64 @@ class FakeShareSnapshot(object): return share_snapshots +class FakeShareTransfer(object): + """Fake a share transfer""" + + @staticmethod + def create_one_transfer(attrs=None, methods=None): + """Create a fake share transfer + + :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) + + share_transfer = { + 'accepted': 'False', + 'auth_key': 'auth-key-' + uuid.uuid4().hex, + 'created_at': now_time.isoformat(), + 'destination_project_id': None, + 'expires_at': delta_time.isoformat(), + 'id': 'transfer-id-' + uuid.uuid4().hex, + 'name': 'name-' + uuid.uuid4().hex, + 'resource_id': 'resource-id-' + uuid.uuid4().hex, + 'resource_type': 'share', + 'source_project_id': 'source-project-id-' + uuid.uuid4().hex + } + + share_transfer.update(attrs) + share_transfer = osc_fakes.FakeResource(info=copy.deepcopy( + share_transfer), + methods=methods, + loaded=True) + return share_transfer + + @staticmethod + def create_share_transfers(attrs=None, count=2): + """Create multiple fake transfers. + + :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 + """ + + share_transfers = [] + for n in range(0, count): + share_transfers.append( + FakeShareSnapshot.create_one_snapshot(attrs)) + + return share_transfers + + class FakeSnapshotAccessRule(object): """Fake one or more snapshot access rules""" diff --git a/manilaclient/tests/unit/osc/v2/test_share_transfers.py b/manilaclient/tests/unit/osc/v2/test_share_transfers.py new file mode 100644 index 000000000..5a74dc982 --- /dev/null +++ b/manilaclient/tests/unit/osc/v2/test_share_transfers.py @@ -0,0 +1,289 @@ +# Copyright (c) 2022 China Telecom Digital Intelligence. +# All Rights Reserved. +# +# 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 osc_lib import exceptions +from osc_lib import utils as oscutils + +from manilaclient import api_versions +from manilaclient.osc.v2 import share_transfers as osc_share_transfers +from manilaclient.tests.unit.osc import osc_utils +from manilaclient.tests.unit.osc.v2 import fakes as manila_fakes + +COLUMNS = [ + 'ID', + 'Name', + 'Resource Type', + 'Resource Id', + 'Created At', + 'Source Project Id', + 'Destination Project Id', + 'Accepted', + 'Expires At' +] + + +class TestShareTransfer(manila_fakes.TestShare): + + def setUp(self): + super(TestShareTransfer, self).setUp() + + self.shares_mock = self.app.client_manager.share.shares + self.shares_mock.reset_mock() + + self.transfers_mock = self.app.client_manager.share.transfers + self.transfers_mock.reset_mock() + + self.app.client_manager.share.api_version = api_versions.APIVersion( + api_versions.MAX_VERSION) + + +class TestShareTransferCreate(TestShareTransfer): + + def setUp(self): + super(TestShareTransferCreate, 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.share_transfer = ( + manila_fakes.FakeShareTransfer.create_one_transfer()) + self.transfers_mock.get.return_value = self.share_transfer + self.transfers_mock.create.return_value = self.share_transfer + + self.cmd = osc_share_transfers.CreateShareTransfer(self.app, None) + + self.data = tuple(self.share_transfer._info.values()) + self.columns = tuple(self.share_transfer._info.keys()) + + def test_share_transfer_create_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_transfer_create_required_args(self): + arglist = [ + self.share.id + ] + verifylist = [ + ('share', self.share.id) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.transfers_mock.create.assert_called_with( + self.share.id, + name=None + ) + + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + +class TestShareTransferDelete(TestShareTransfer): + + def setUp(self): + super(TestShareTransferDelete, self).setUp() + + self.transfer = ( + manila_fakes.FakeShareTransfer.create_one_transfer()) + + self.transfers_mock.get.return_value = self.transfer + + self.cmd = osc_share_transfers.DeleteShareTransfer(self.app, None) + + def test_share_transfer_delete_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_transfer_delete(self): + arglist = [ + self.transfer.id + ] + verifylist = [ + ('transfer', [self.transfer.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.transfers_mock.delete.assert_called_with(self.transfer.id) + self.assertIsNone(result) + + def test_share_transfer_delete_multiple(self): + transfers = ( + manila_fakes.FakeShareTransfer.create_share_transfers( + count=2)) + arglist = [ + transfers[0].id, + transfers[1].id + ] + verifylist = [ + ('transfer', [transfers[0].id, transfers[1].id]) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.assertEqual(self.transfers_mock.delete.call_count, + len(transfers)) + self.assertIsNone(result) + + def test_share_transfer_delete_exception(self): + arglist = [ + self.transfer.id + ] + verifylist = [ + ('transfer', [self.transfer.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.transfers_mock.delete.side_effect = exceptions.CommandError() + self.assertRaises(exceptions.CommandError, + self.cmd.take_action, + parsed_args) + + +class TestShareTransferShow(TestShareTransfer): + + def setUp(self): + super(TestShareTransferShow, self).setUp() + + self.transfer = ( + manila_fakes.FakeShareTransfer.create_one_transfer()) + self.transfers_mock.get.return_value = self.transfer + + self.cmd = osc_share_transfers.ShowShareTransfer(self.app, None) + + self.data = self.transfer._info.values() + self.transfer._info.pop('auth_key') + self.columns = self.transfer._info.keys() + + def test_share_transfer_show_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_transfer_show(self): + arglist = [ + self.transfer.id + ] + verifylist = [ + ('transfer', self.transfer.id) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + self.transfers_mock.get.assert_called_with(self.transfer.id) + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + +class TestShareTransferList(TestShareTransfer): + + def setUp(self): + super(TestShareTransferList, self).setUp() + + self.transfers = ( + manila_fakes.FakeShareTransfer.create_share_transfers( + count=2)) + + self.transfers_mock.list.return_value = self.transfers + + self.values = (oscutils.get_dict_properties( + m._info, COLUMNS) for m in self.transfers) + + self.cmd = osc_share_transfers.ListShareTransfer(self.app, None) + + def test_list_transfers(self): + arglist = [ + '--detailed' + ] + verifylist = [ + ('detailed', True) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.transfers_mock.list.assert_called_with( + detailed=1, + search_opts={ + 'all_tenants': False, + 'id': None, + 'name': None, + 'limit': None, + 'offset': None, + 'resource_type': None, + 'resource_id': None, + 'source_project_id': None}, + sort_key=None, + sort_dir=None + ) + + self.assertEqual(COLUMNS, columns) + self.assertEqual(list(self.values), list(data)) + + +class TestShareTransferAccept(TestShareTransfer): + + def setUp(self): + super(TestShareTransferAccept, self).setUp() + + self.transfer = ( + manila_fakes.FakeShareTransfer.create_one_transfer()) + + self.transfers_mock.get.return_value = self.transfer + + self.cmd = osc_share_transfers.AcceptShareTransfer(self.app, None) + + def test_share_transfer_accept_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_transfer_accept(self): + arglist = [ + self.transfer.id, + self.transfer.auth_key + ] + verifylist = [ + ('transfer', self.transfer.id), + ('auth_key', self.transfer.auth_key) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.transfers_mock.accept.assert_called_with(self.transfer.id, + self.transfer.auth_key, + clear_access_rules=False) + self.assertIsNone(result) diff --git a/manilaclient/tests/unit/v2/fakes.py b/manilaclient/tests/unit/v2/fakes.py index e34866800..9290a6bcc 100644 --- a/manilaclient/tests/unit/v2/fakes.py +++ b/manilaclient/tests/unit/v2/fakes.py @@ -1317,6 +1317,19 @@ class FakeHTTPClient(fakes.FakeHTTPClient): 'request_id': 'req-936666d2-4c8f-4e41-9ac9-237b43f8b848', } + fake_transfer = { + "id": "f21c72c4-2b77-445b-aa12-e8d1b44163a2", + "created_at": "2022-09-06T08:17:43.629495", + "name": "test_transfer", + "resource_type": "share", + "resource_id": "29476819-28a9-4b1a-a21d-3b2d203025a0", + "auth_key": "406a2d67cdb09afe", + "source_project_id": "714198c7ac5e45a4b785de732ea4695d", + "destination_project_id": None, + "accepted": False, + "expires_at": None, + } + def get_messages(self, **kw): messages = { 'messages': [self.fake_message], @@ -1333,6 +1346,25 @@ class FakeHTTPClient(fakes.FakeHTTPClient): def delete_messages_5678(self, **kw): return 202, {}, None + def post_share_transfers(self, **kw): + transfer = {'transfer': self.fake_transfer} + return 202, {}, transfer + + def get_share_transfers_5678(self, **kw): + transfer = {'transfer': self.fake_transfer} + return 202, {}, transfer + + def get_share_transfers_detail(self, **kw): + transfer = {'transfers': [self.fake_transfer]} + return 202, {}, transfer + + def delete_share_transfers_5678(self, **kw): + return 202, {}, None + + def post_share_transfers_5678_accept(self, **kw): + transfer = {'transfer': self.fake_transfer} + return 202, {}, transfer + def fake_create(url, body, response_key): return {'url': url, 'body': body, 'resp_key': response_key} diff --git a/manilaclient/tests/unit/v2/test_share_transfers.py b/manilaclient/tests/unit/v2/test_share_transfers.py new file mode 100644 index 000000000..6cec52b5e --- /dev/null +++ b/manilaclient/tests/unit/v2/test_share_transfers.py @@ -0,0 +1,50 @@ +# Copyright (c) 2022 China Telecom Digital Intelligence. +# All Rights Reserved. +# +# 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.tests.unit import utils +from manilaclient.tests.unit.v2 import fakes + +TRANSFER_URL = 'share-transfers' +cs = fakes.FakeClient(api_versions.APIVersion('2.77')) + + +class ShareTransfersTest(utils.TestCase): + + def test_create(self): + cs.transfers.create('1234') + cs.assert_called('POST', '/%s' % TRANSFER_URL, + body={'transfer': {'share_id': '1234', + 'name': None}}) + + def test_get(self): + transfer_id = '5678' + cs.transfers.get(transfer_id) + cs.assert_called('GET', '/%s/%s' % (TRANSFER_URL, transfer_id)) + + def test_list(self): + cs.transfers.list() + cs.assert_called('GET', '/%s/detail' % TRANSFER_URL) + + def test_delete(self): + cs.transfers.delete('5678') + cs.assert_called('DELETE', '/%s/5678' % TRANSFER_URL) + + def test_accept(self): + transfer_id = '5678' + auth_key = '12345' + cs.transfers.accept(transfer_id, auth_key) + cs.assert_called('POST', + '/%s/%s/accept' % (TRANSFER_URL, transfer_id)) diff --git a/manilaclient/v2/client.py b/manilaclient/v2/client.py index a0058c743..b9d061784 100644 --- a/manilaclient/v2/client.py +++ b/manilaclient/v2/client.py @@ -43,6 +43,7 @@ from manilaclient.v2 import share_snapshot_export_locations from manilaclient.v2 import share_snapshot_instance_export_locations from manilaclient.v2 import share_snapshot_instances from manilaclient.v2 import share_snapshots +from manilaclient.v2 import share_transfers from manilaclient.v2 import share_type_access from manilaclient.v2 import share_types from manilaclient.v2 import shares @@ -189,6 +190,7 @@ class Client(object): self.availability_zones = availability_zones.AvailabilityZoneManager( self) self.limits = limits.LimitsManager(self) + self.transfers = share_transfers.ShareTransferManager(self) self.messages = messages.MessageManager(self) self.services = services.ServiceManager(self) self.security_services = security_services.SecurityServiceManager(self) diff --git a/manilaclient/v2/share_transfers.py b/manilaclient/v2/share_transfers.py new file mode 100644 index 000000000..3304d89a2 --- /dev/null +++ b/manilaclient/v2/share_transfers.py @@ -0,0 +1,121 @@ +# Copyright (c) 2022 China Telecom Digital Intelligence. +# All Rights Reserved. +# +# 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 ShareTransfer(base.Resource): + """Transfer a share from one tenant to another""" + + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this share transfer.""" + return self.manager.delete(self) + + +class ShareTransferManager(base.ManagerWithFind): + """Manage :class:`ShareTransfer` resources.""" + resource_class = ShareTransfer + + @api_versions.wraps(constants.SHARE_TRANSFER_VERSION) + def create(self, share_id, name=None): + """Creates a share transfer. + + :param share_id: The ID of the share to transfer. + :param name: The name of the transfer. + :rtype: :class:`ShareTransfer` + """ + body = {'transfer': {'share_id': share_id, + 'name': name}} + + return self._create('/share-transfers', body, 'transfer') + + @api_versions.wraps(constants.SHARE_TRANSFER_VERSION) + def accept(self, transfer, auth_key, clear_access_rules=False): + """Accept a share transfer. + + :param transfer_id: The ID of the transfer to accept. + :param auth_key: The auth_key of the transfer. + :param clear_access_rules: Transfer share without access rules + :rtype: :class:`ShareTransfer` + """ + transfer_id = base.getid(transfer) + body = {'accept': {'auth_key': auth_key, + 'clear_access_rules': clear_access_rules}} + + self._accept('/share-transfers/%s/accept' % transfer_id, body) + + @api_versions.wraps(constants.SHARE_TRANSFER_VERSION) + def get(self, transfer_id): + """Show details of a share transfer. + + :param transfer_id: The ID of the share transfer to display. + :rtype: :class:`ShareTransfer` + """ + return self._get("/share-transfers/%s" % transfer_id, "transfer") + + @api_versions.wraps(constants.SHARE_TRANSFER_VERSION) + def list(self, detailed=True, search_opts=None, + sort_key=None, sort_dir=None): + """Get a list of all share transfer. + + :param detailed: Get detailed object information. + :param search_opts: Filtering options. + :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:`ShareTransfer` + """ + if search_opts is None: + search_opts = {} + + if sort_key is not None: + if sort_key in constants.SHARE_TRANSFER_SORT_KEY_VALUES: + search_opts['sort_key'] = sort_key + # NOTE: Replace aliases with appropriate keys + if sort_key == 'name': + search_opts['sort_key'] = 'display_name' + else: + raise ValueError( + 'sort_key must be one of the following: %s.' + % ', '.join(constants.SHARE_TRANSFER_SORT_KEY_VALUES)) + + if sort_dir is not None: + 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) + + if detailed: + path = "/share-transfers/detail%s" % (query_string,) + else: + path = "/share-transfers%s" % (query_string,) + + return self._list(path, 'transfers') + + @api_versions.wraps(constants.SHARE_TRANSFER_VERSION) + def delete(self, transfer_id): + """Delete a share transfer. + + :param transfer_id: The :class:`ShareTransfer` to delete. + """ + + return self._delete("/share-transfers/%s" % base.getid(transfer_id)) diff --git a/manilaclient/v2/shell.py b/manilaclient/v2/shell.py index a4aea3cd8..081a2c06d 100644 --- a/manilaclient/v2/shell.py +++ b/manilaclient/v2/shell.py @@ -125,6 +125,11 @@ def _find_share(cs, share): return apiclient_utils.find_resource(cs.shares, share) +def _find_share_transfer(cs, transfer): + """Get a share transfer by ID.""" + return apiclient_utils.find_resource(cs.transfers, transfer) + + @api_versions.wraps("1.0", "2.8") def _print_share(cs, share): info = share._info.copy() @@ -6532,6 +6537,212 @@ def do_share_replica_resync(cs, args): cs.share_replicas.resync(replica) +############################################################################## +# +# Share Transfer +# +############################################################################## + + +def _print_share_transfer(transfer): + info = transfer._info.copy() + info.pop('links', None) + + cliutils.print_dict(info) + + +@api_versions.wraps("2.77") +@cliutils.arg( + 'share', + metavar='', + help='Name or ID of share to transfer.') +@cliutils.arg( + '--name', + metavar='', + default=None, + help='Transfer name. Default=None.') +def do_share_transfer_create(cs, args): + """Creates a share transfer.""" + share = _find_share(cs, args.share) + transfer = cs.transfers.create(share.id, + args.name) + _print_share_transfer(transfer) + + +@api_versions.wraps("2.77") +@cliutils.arg( + 'transfer', + metavar='', + nargs='+', + help='ID or name of the transfer(s).') +def do_share_transfer_delete(cs, args): + """Remove one or more transfers.""" + failure_count = 0 + + for transfer in args.transfer: + try: + transfer_ref = _find_share_transfer(cs, transfer) + transfer_ref.delete() + except Exception as e: + failure_count += 1 + print("Delete for share transfer %s failed: %s" % (transfer, e), + file=sys.stderr) + + if failure_count == len(args.transfer): + raise exceptions.CommandError("Unable to delete any of the specified " + "transfers.") + + +@api_versions.wraps("2.77") +@cliutils.arg( + 'transfer', + metavar='', + help='ID of transfer to accept.') +@cliutils.arg( + 'auth_key', + metavar='', + help='Authentication key of transfer to accept.') +@cliutils.arg( + '--clear-rules', + '--clear_rules', + dest='clear_rules', + action='store_true', + default=False, + help="Whether manila should clean up the access rules after the " + "transfer is complete. (Default=False)") +def do_share_transfer_accept(cs, args): + """Accepts a share transfer.""" + cs.transfers.accept(args.transfer, args.auth_key, + clear_access_rules=args.clear_rules) + + +@api_versions.wraps("2.77") +@cliutils.arg( + '--all-tenants', '--all-projects', + action='single_alias', + dest='all_projects', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. (Admin only).') +@cliutils.arg( + '--name', + metavar='', + default=None, + action='single_alias', + help='Transfer name. Default=None.') +@cliutils.arg( + '--id', + metavar='', + default=None, + action='single_alias', + help='Transfer ID. Default=None.') +@cliutils.arg( + '--resource-type', '--resource_type', + metavar='', + default=None, + action='single_alias', + help='Transfer type, which can be share or network. Default=None.') +@cliutils.arg( + '--resource-id', '--resource_id', + metavar='', + default=None, + action='single_alias', + help='Transfer resource id. Default=None.') +@cliutils.arg( + '--source-project-id', '--source_project_id', + metavar='', + default=None, + action='single_alias', + help='Transfer source project id. Default=None.') +@cliutils.arg( + '--limit', + metavar='', + type=int, + default=None, + help='Maximum number of messages to return. (Default=None)') +@cliutils.arg( + '--offset', + metavar="", + default=None, + help='Start position of message listing.') +@cliutils.arg( + '--sort-key', '--sort_key', + metavar='', + type=str, + default=None, + action='single_alias', + help='Key to be sorted, available keys are %(keys)s. Default=None.' + % {'keys': constants.SHARE_TRANSFER_SORT_KEY_VALUES}) +@cliutils.arg( + '--sort-dir', '--sort_dir', + metavar='', + type=str, + default=None, + action='single_alias', + help='Sort direction, available values are %(values)s. ' + 'Optional: Default=None.' % {'values': constants.SORT_DIR_VALUES}) +@cliutils.arg( + '--detailed', + dest='detailed', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help="Show detailed information about filtered share transfers.") +@cliutils.arg( + '--columns', + metavar='', + type=str, + default=None, + help='Comma separated list of columns to be displayed ' + 'example --columns "id,resource_id".') +def do_share_transfer_list(cs, args): + """Lists all transfers.""" + if args.columns is not None: + list_of_keys = _split_columns(columns=args.columns) + else: + list_of_keys = ['ID', 'Name', 'Resource Type', 'Resource Id'] + + if args.detailed: + list_of_keys.extend(['Created At', 'Expires At', 'Source Project Id', + 'Destination Project Id', 'Accepted']) + + all_projects = int( + os.environ.get("ALL_TENANTS", + os.environ.get("ALL_PROJECTS", + args.all_projects)) + ) + + search_opts = { + 'offset': args.offset, + 'limit': args.limit, + 'all_tenants': all_projects, + 'id': args.id, + 'name': args.name, + 'resource_type': args.resource_type, + 'resource_id': args.resource_id, + 'source_project_id': args.source_project_id, + } + share_transfers = cs.transfers.list( + detailed=args.detailed, search_opts=search_opts, + sort_key=args.sort_key, sort_dir=args.sort_dir) + cliutils.print_list(share_transfers, fields=list_of_keys, + sortby_index=None) + + +@api_versions.wraps("2.77") +@cliutils.arg( + 'transfer', + metavar='', + help='Name or ID of transfer to show.') +def do_share_transfer_show(cs, args): + """Delete a transfer.""" + transfer = _find_share_transfer(cs, args.transfer) + _print_share_transfer(transfer) ############################################################################## # # User Messages diff --git a/releasenotes/notes/bp-support-share-transfer-between-project-faefead551380eca.yaml b/releasenotes/notes/bp-support-share-transfer-between-project-faefead551380eca.yaml new file mode 100644 index 000000000..0f9a929af --- /dev/null +++ b/releasenotes/notes/bp-support-share-transfer-between-project-faefead551380eca.yaml @@ -0,0 +1,3 @@ +--- +features: + - Support transferring shares between projects starting from API version ``2.77``. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 2d7a50701..f12afb19d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -159,6 +159,11 @@ openstack.share.v2 = share_server_migration_complete = manilaclient.osc.v2.share_servers:ShareServerMigrationComplete share_server_migration_show = manilaclient.osc.v2.share_servers:ShareServerMigrationShow share_server_migration_start = manilaclient.osc.v2.share_servers:ShareServerMigrationStart + share_transfer_create = manilaclient.osc.v2.share_transfers:CreateShareTransfer + share_transfer_delete = manilaclient.osc.v2.share_transfers:DeleteShareTransfer + 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 [coverage:run] omit = manilaclient/tests/*