From 5e24577904927930489ddf6f879856c86d6fb440 Mon Sep 17 00:00:00 2001 From: Kiran Pawar Date: Sat, 30 Apr 2022 19:42:11 +0530 Subject: [PATCH] Implement share backup Add share backup feature. This will allow the user to create, restore and delete backups as well as listing backups and showing the details of a specific backup. Implement: blueprint share-backup Depends-On: Ice01ab7892b1eb52b3202f2c79957977f73f3aca Change-Id: I2c3848cbbeb921ede74756e25e58ef82277e0d2b --- manilaclient/common/constants.py | 13 + manilaclient/osc/v2/share_backups.py | 416 +++++++++++++++++ manilaclient/tests/functional/osc/base.py | 28 ++ .../functional/osc/test_share_backups.py | 152 +++++++ manilaclient/tests/unit/osc/v2/fakes.py | 59 +++ .../tests/unit/osc/v2/test_share_backups.py | 420 ++++++++++++++++++ .../tests/unit/v2/test_share_backups.py | 91 ++++ manilaclient/v2/client.py | 2 + manilaclient/v2/share_backups.py | 137 ++++++ .../notes/share-backup-98e11c6a28897e94.yaml | 7 + setup.cfg | 7 + 11 files changed, 1332 insertions(+) create mode 100644 manilaclient/osc/v2/share_backups.py create mode 100644 manilaclient/tests/functional/osc/test_share_backups.py create mode 100644 manilaclient/tests/unit/osc/v2/test_share_backups.py create mode 100644 manilaclient/tests/unit/v2/test_share_backups.py create mode 100644 manilaclient/v2/share_backups.py create mode 100644 releasenotes/notes/share-backup-98e11c6a28897e94.yaml diff --git a/manilaclient/common/constants.py b/manilaclient/common/constants.py index 275e538b2..08e609f9a 100644 --- a/manilaclient/common/constants.py +++ b/manilaclient/common/constants.py @@ -89,6 +89,19 @@ RESOURCE_LOCK_SORT_KEY_VALUES = ( 'lock_reason', ) +BACKUP_SORT_KEY_VALUES = ( + 'id', + 'status', + 'size', + 'share_id', + 'progress', + 'restore_progress', + 'name', + 'host', + 'topic', + 'project_id', +) + TASK_STATE_MIGRATION_SUCCESS = 'migration_success' TASK_STATE_MIGRATION_ERROR = 'migration_error' TASK_STATE_MIGRATION_CANCELLED = 'migration_cancelled' diff --git a/manilaclient/osc/v2/share_backups.py b/manilaclient/osc/v2/share_backups.py new file mode 100644 index 000000000..8c9dc9e07 --- /dev/null +++ b/manilaclient/osc/v2/share_backups.py @@ -0,0 +1,416 @@ +# Copyright 2023 Cloudification GmbH. +# 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. + +import logging + +from osc_lib.cli import parseractions +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 import constants +from manilaclient.osc import utils + +LOG = logging.getLogger(__name__) + + +class CreateShareBackup(command.ShowOne): + """Create a share backup.""" + _description = _("Create a backup of the given share") + + def get_parser(self, prog_name): + parser = super(CreateShareBackup, self).get_parser(prog_name) + parser.add_argument( + "share", + metavar="", + help=_("Name or ID of the share to backup.") + ) + parser.add_argument( + '--name', + metavar='', + default=None, + help=_('Optional share backup name. (Default=None).') + ) + parser.add_argument( + '--description', + metavar='', + default=None, + help=_('Optional share backup description. (Default=None).') + ) + parser.add_argument( + "--backup-options", + metavar="", + default={}, + action=parseractions.KeyValueAction, + help=_("Backup driver option key=value pairs (Optional, " + "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) + + body = {} + if parsed_args.backup_options: + body['backup_options'] = utils.extract_key_value_options( + parsed_args.backup_options) + if parsed_args.description: + body['description'] = parsed_args.description + if parsed_args.name: + body['name'] = parsed_args.name + + share_backup = share_client.share_backups.create(share, **body) + share_backup._info.pop('links', None) + return self.dict2columns(share_backup._info) + + +class DeleteShareBackup(command.Command): + """Delete one or more share backups.""" + _description = _("Delete one or more share backups") + + def get_parser(self, prog_name): + parser = super(DeleteShareBackup, self).get_parser(prog_name) + parser.add_argument( + "backup", + metavar="", + nargs="+", + help=_("Name or ID of the backup(s) to delete") + ) + parser.add_argument( + "--wait", + action='store_true', + default=False, + help=_("Wait for share backup deletion") + ) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + result = 0 + + for backup in parsed_args.backup: + try: + share_backup_obj = osc_utils.find_resource( + share_client.share_backups, backup) + share_client.share_backups.delete(share_backup_obj) + + if parsed_args.wait: + if not osc_utils.wait_for_delete( + manager=share_client.share_backups, + res_id=share_backup_obj.id): + result += 1 + + except Exception as e: + result += 1 + LOG.error(_( + "Failed to delete a share backup with " + "name or ID '%(backup)s': %(e)s"), + {'backup': backup, 'e': e}) + + if result > 0: + total = len(parsed_args.backup) + msg = (_("%(result)s of %(total)s backups failed " + "to delete.") % {'result': result, 'total': total}) + raise exceptions.CommandError(msg) + + +class ListShareBackup(command.Lister): + """List share backups.""" + _description = _("List share backups") + + def get_parser(self, prog_name): + parser = super(ListShareBackup, self).get_parser(prog_name) + parser.add_argument( + "--share", + metavar="", + default=None, + help=_("Name or ID of the share to list backups for.") + ) + parser.add_argument( + "--name", + metavar="", + default=None, + help=_("Filter results by name. Default=None.") + ) + parser.add_argument( + '--description', + metavar="", + default=None, + help=_("Filter results by description. Default=None.") + ) + parser.add_argument( + "--name~", + metavar="", + default=None, + help=_("Filter results matching a share backup name pattern. ") + ) + parser.add_argument( + '--description~', + metavar="", + default=None, + help=_("Filter results matching a share backup description ") + ) + parser.add_argument( + '--status', + metavar="", + default=None, + help=_('Filter results by status. Default=None.') + ) + parser.add_argument( + "--limit", + metavar="", + type=int, + default=None, + action=parseractions.NonNegativeAction, + help=_("Limit the number of backups returned. Default=None.") + ) + parser.add_argument( + '--offset', + metavar="", + default=None, + help='Start position of backup 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.BACKUP_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( + '--detail', + dest='detail', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help="Show detailed information about share backups.") + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + + share_id = None + if parsed_args.share: + share_id = osc_utils.find_resource(share_client.shares, + parsed_args.share).id + columns = [ + 'ID', + 'Name', + 'Share ID', + 'Status' + ] + + if parsed_args.detail: + columns.extend(['Description', 'Size', 'Created At', + 'Updated At', 'Availability Zone', 'Progress', + 'Restore Progress', 'Host', 'Topic']) + + search_opts = { + 'limit': parsed_args.limit, + 'offset': parsed_args.offset, + 'name': parsed_args.name, + 'description': parsed_args.description, + 'status': parsed_args.status, + 'share_id': share_id, + } + + search_opts['name~'] = getattr(parsed_args, 'name~') + search_opts['description~'] = getattr(parsed_args, 'description~') + + backups = share_client.share_backups.list( + detailed=parsed_args.detail, search_opts=search_opts, + sort_key=parsed_args.sort_key, sort_dir=parsed_args.sort_dir) + + return (columns, + (osc_utils.get_item_properties(b, columns) for b in backups)) + + +class ShowShareBackup(command.ShowOne): + """Show share backup.""" + _description = _("Show details of a backup") + + def get_parser(self, prog_name): + parser = super(ShowShareBackup, self).get_parser(prog_name) + parser.add_argument( + "backup", + metavar="", + help=_("ID of the share backup. ") + ) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + backup = osc_utils.find_resource(share_client.share_backups, + parsed_args.backup) + backup._info.pop('links', None) + return self.dict2columns(backup._info) + + +class RestoreShareBackup(command.Command): + """Restore share backup to share""" + _description = _("Attempt to restore share backup") + + def get_parser(self, prog_name): + parser = super(RestoreShareBackup, self).get_parser(prog_name) + parser.add_argument( + "backup", + metavar="", + help=_('ID of backup to restore.') + ) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + share_backup = osc_utils.find_resource( + share_client.share_backups, + parsed_args.backup) + share_client.share_backups.restore(share_backup.id) + + +class SetShareBackup(command.Command): + """Set share backup properties.""" + _description = _("Set share backup properties") + + def get_parser(self, prog_name): + parser = super(SetShareBackup, self).get_parser(prog_name) + parser.add_argument( + "backup", + metavar="", + help=_('Name or ID of the backup to set a property for') + ) + parser.add_argument( + "--name", + metavar="", + default=None, + help=_("Set a name to the backup.") + ) + parser.add_argument( + "--description", + metavar="", + default=None, + help=_("Set a description to the backup.") + ) + parser.add_argument( + "--status", + metavar="", + choices=['available', 'error', 'creating', 'deleting', + 'restoring'], + help=_("Assign a status to the backup(Admin only). " + "Options include : available, error, creating, " + "deleting, restoring.") + ) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + result = 0 + + share_backup = osc_utils.find_resource( + share_client.share_backups, + parsed_args.backup) + + kwargs = {} + + if parsed_args.name is not None: + kwargs['name'] = parsed_args.name + if parsed_args.description is not None: + kwargs['description'] = parsed_args.description + + try: + share_client.share_backups.update(share_backup, **kwargs) + except Exception as e: + result += 1 + LOG.error(_( + "Failed to set share backup properties " + "'%(properties)s': %(exception)s"), + {'properties': kwargs, + 'exception': e}) + + if parsed_args.status: + try: + share_client.share_backups.reset_status( + share_backup, + parsed_args.status + ) + except Exception as e: + result += 1 + LOG.error(_( + "Failed to update backup status to " + "'%(status)s': %(e)s"), + {'status': parsed_args.status, 'e': e}) + if result > 0: + raise exceptions.CommandError(_("One or more of the " + "set operations failed")) + + +class UnsetShareBackup(command.Command): + """Unset share backup properties.""" + _description = _("Unset share backup properties") + + def get_parser(self, prog_name): + parser = super(UnsetShareBackup, self).get_parser(prog_name) + parser.add_argument( + "backup", + metavar="", + help=_('Name or ID of the backup to unset a property for') + ) + parser.add_argument( + "--name", + action='store_true', + help=_("Unset a name to the backup.") + ) + parser.add_argument( + "--description", + action='store_true', + help=_("Unset a description to the backup.") + ) + return parser + + def take_action(self, parsed_args): + share_client = self.app.client_manager.share + share_backup = osc_utils.find_resource( + share_client.share_backups, + parsed_args.backup) + + kwargs = {} + if parsed_args.name: + kwargs['name'] = None + if parsed_args.description: + kwargs['description'] = None + if not kwargs: + msg = "Either name or description must be provided." + raise exceptions.CommandError(msg) + + try: + share_client.share_backups.update(share_backup, **kwargs) + except Exception as e: + LOG.error(_( + "Failed to unset share backup properties " + "'%(properties)s': %(exception)s"), + {'properties': kwargs, + 'exception': e}) diff --git a/manilaclient/tests/functional/osc/base.py b/manilaclient/tests/functional/osc/base.py index 8d1c6a0ca..ec49459a5 100644 --- a/manilaclient/tests/functional/osc/base.py +++ b/manilaclient/tests/functional/osc/base.py @@ -434,3 +434,31 @@ class OSCClientTestBase(base.ClientTestBase): 'share lock delete %s' % lock['id'], client=client) return lock + + def create_backup(self, share_id, name=None, description=None, + backup_options=None, add_cleanup=True): + + name = name or data_utils.rand_name('autotest_backup_name') + + cmd = (f'backup create {share_id} ') + + if name: + cmd += f' --name {name}' + if description: + cmd += f' --description {description}' + if backup_options: + options = ' --backup-options' + for key, value in backup_options.items(): + options += f' {key}={value}' + cmd += options + + backup_object = self.dict_result('share', cmd) + self._wait_for_object_status( + 'share backup', backup_object['id'], 'available') + + if add_cleanup: + self.addCleanup( + self.openstack, + f'share backup delete {backup_object["id"]} --wait') + + return backup_object diff --git a/manilaclient/tests/functional/osc/test_share_backups.py b/manilaclient/tests/functional/osc/test_share_backups.py new file mode 100644 index 000000000..6c7ed6dd4 --- /dev/null +++ b/manilaclient/tests/functional/osc/test_share_backups.py @@ -0,0 +1,152 @@ +# 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 json + +from manilaclient.tests.functional.osc import base + + +class ShareBackupCLITest(base.OSCClientTestBase): + """Functional tests for share backup.""" + + def test_share_backup_create(self): + share = self.create_share() + + backup = self.create_backup( + share_id=share['id'], + name='test_backup_create', + description='Description', + backup_options={'dummy': True}) + + # fetch latest after periodic callback updates status + backup = json.loads(self.openstack( + f'share backup show -f json {backup["id"]}')) + + self.assertEqual(share["id"], backup["share_id"]) + self.assertEqual('test_backup_create', backup["name"]) + self.assertEqual('Description', backup["description"]) + self.assertEqual('available', backup["status"]) + + backups_list = self.listing_result('share backup', 'list') + self.assertIn(backup['id'], + [item['ID'] for item in backups_list]) + + def test_share_backup_delete(self): + share = self.create_share() + + backup = self.create_backup( + share_id=share['id'], + backup_options={'dummy': True}, + add_cleanup=False) + + self.openstack( + f'share backup delete {backup["id"]} --wait') + + self.check_object_deleted('share backup', backup["id"]) + + def test_share_backup_show(self): + share = self.create_share() + + backup = self.create_backup( + share_id=share['id'], + name='test_backup_show', + description='Description', + backup_options={'dummy': True}) + + show_result = self.dict_result( + 'share backup', f'show {backup["id"]}') + + self.assertEqual(backup["id"], show_result["id"]) + self.assertEqual('test_backup_show', show_result["name"]) + self.assertEqual('Description', show_result["description"]) + + def test_share_backup_set(self): + share = self.create_share() + + backup = self.create_backup(share_id=share['id'], + backup_options={'dummy': True}) + + self.openstack( + f'share backup set {backup["id"]} ' + f'--name test_backup_set --description Description') + + show_result = self.dict_result( + 'share backup ', f'show {backup["id"]}') + + self.assertEqual(backup['id'], show_result["id"]) + self.assertEqual('test_backup_set', show_result["name"]) + self.assertEqual('Description', show_result["description"]) + + def test_share_backup_unset(self): + share = self.create_share() + + backup = self.create_backup( + share_id=share['id'], + name='test_backup_unset', + description='Description', + backup_options={'dummy': True}) + + self.openstack( + f'share backup unset {backup["id"]} --name --description') + + show_result = json.loads(self.openstack( + f'share backup show -f json {backup["id"]}')) + + self.assertEqual(backup['id'], show_result["id"]) + self.assertIsNone(show_result["name"]) + self.assertIsNone(show_result["description"]) + + def test_share_backup_list(self): + share_1 = self.create_share() + share_2 = self.create_share() + + backup_1 = self.create_backup(share_id=share_1['id'], + backup_options={'dummy': True}) + backup_2 = self.create_backup(share_id=share_2['id'], + backup_options={'dummy': True}) + + backups_list = self.listing_result( + 'share backup', f'list --name {backup_2["name"]} ' + ) + + self.assertTableStruct(backups_list, [ + 'ID', + 'Name', + 'Share ID', + 'Status' + ]) + self.assertEqual(1, len(backups_list)) + self.assertIn(backup_2['id'], + [item['ID'] for item in backups_list]) + + backups_list = self.listing_result( + 'share backup', f'list --share {share_1["id"]} --detail' + ) + + self.assertTableStruct(backups_list, [ + 'ID', + 'Name', + 'Share ID', + 'Status', + 'Description', + 'Availability Zone', + 'Created At', + 'Updated At', + 'Size', + 'Progress', + 'Restore Progress', + 'Host', + 'Topic', + ]) + self.assertEqual(1, len(backups_list)) + self.assertIn(backup_1['id'], + [item['ID'] for item in backups_list]) diff --git a/manilaclient/tests/unit/osc/v2/fakes.py b/manilaclient/tests/unit/osc/v2/fakes.py index 79a583b04..9b0a3b7b2 100644 --- a/manilaclient/tests/unit/osc/v2/fakes.py +++ b/manilaclient/tests/unit/osc/v2/fakes.py @@ -37,6 +37,7 @@ class FakeShareClient(object): self.share_type_access = mock.Mock() self.quotas = mock.Mock() self.quota_classes = mock.Mock() + self.share_backups = mock.Mock() self.share_snapshots = mock.Mock() self.share_group_snapshots = mock.Mock() self.share_snapshot_export_locations = mock.Mock() @@ -1580,3 +1581,61 @@ class FakeResourceLock(object): FakeResourceLock.create_one_lock(attrs)) return resource_locks + + +class FakeShareBackup(object): + """Fake a share Backup""" + + @staticmethod + def create_one_backup(attrs=None, methods=None): + """Create a fake share backup + + :param Dictionary attrs: + A dictionary with all attributes + :return: + A FakeResource object, with project_id, resource and so on + """ + + attrs = attrs or {} + methods = methods or {} + + share_backup = { + 'id': 'backup-id-' + uuid.uuid4().hex, + 'share_id': 'share-id-' + uuid.uuid4().hex, + 'status': None, + 'name': None, + 'description': None, + 'size': '0', + 'created_at': datetime.datetime.now().isoformat(), + 'updated_at': datetime.datetime.now().isoformat(), + 'availability_zone': None, + 'progress': None, + 'restore_progress': None, + 'host': None, + 'topic': None, + } + + share_backup.update(attrs) + share_backup = osc_fakes.FakeResource(info=copy.deepcopy( + share_backup), + methods=methods, + loaded=True) + return share_backup + + @staticmethod + def create_share_backups(attrs=None, count=2): + """Create multiple fake backups. + + :param Dictionary attrs: + A dictionary with all attributes + :param Integer count: + The number of share backups to be faked + :return: + A list of FakeResource objects + """ + + share_backups = [] + for n in range(0, count): + share_backups.append( + FakeShareBackup.create_one_backup(attrs)) + return share_backups diff --git a/manilaclient/tests/unit/osc/v2/test_share_backups.py b/manilaclient/tests/unit/osc/v2/test_share_backups.py new file mode 100644 index 000000000..5c8cdb653 --- /dev/null +++ b/manilaclient/tests/unit/osc/v2/test_share_backups.py @@ -0,0 +1,420 @@ +# 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.api_versions import MAX_VERSION +from manilaclient.osc.v2 import share_backups as osc_share_backups +from manilaclient.tests.unit.osc import osc_utils +from manilaclient.tests.unit.osc.v2 import fakes as manila_fakes + + +class TestShareBackup(manila_fakes.TestShare): + + def setUp(self): + super(TestShareBackup, self).setUp() + + self.shares_mock = self.app.client_manager.share.shares + self.shares_mock.reset_mock() + + self.backups_mock = self.app.client_manager.share.share_backups + self.backups_mock.reset_mock() + self.app.client_manager.share.api_version = api_versions.APIVersion( + MAX_VERSION) + + +class TestShareBackupCreate(TestShareBackup): + + def setUp(self): + super(TestShareBackupCreate, self).setUp() + + self.share = manila_fakes.FakeShare.create_one_share() + self.shares_mock.get.return_value = self.share + + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup( + attrs={'status': 'available'} + )) + self.backups_mock.create.return_value = self.share_backup + self.backups_mock.get.return_value = self.share_backup + self.cmd = osc_share_backups.CreateShareBackup(self.app, None) + self.data = tuple(self.share_backup._info.values()) + self.columns = tuple(self.share_backup._info.keys()) + + def test_share_backup_create_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises( + osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_backup_create(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.backups_mock.create.assert_called_with( + self.share, + ) + + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + def test_share_backup_create_name(self): + arglist = [ + self.share.id, + '--name', "FAKE_SHARE_BACKUP_NAME" + ] + verifylist = [ + ('share', self.share.id), + ('name', "FAKE_SHARE_BACKUP_NAME") + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + self.backups_mock.create.assert_called_with( + self.share, + name="FAKE_SHARE_BACKUP_NAME", + ) + + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + +class TestShareBackupDelete(TestShareBackup): + + def setUp(self): + super(TestShareBackupDelete, self).setUp() + + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup()) + self.backups_mock.get.return_value = self.share_backup + + self.cmd = osc_share_backups.DeleteShareBackup(self.app, None) + + def test_share_backup_delete_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises(osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_backup_delete(self): + arglist = [ + self.share_backup.id + ] + verifylist = [ + ('backup', [self.share_backup.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + self.backups_mock.delete.assert_called_with(self.share_backup) + self.assertIsNone(result) + + def test_share_backup_delete_multiple(self): + share_backups = ( + manila_fakes.FakeShareBackup.create_share_backups( + count=2)) + arglist = [ + share_backups[0].id, + share_backups[1].id + ] + verifylist = [ + ('backup', [share_backups[0].id, share_backups[1].id]) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + self.assertEqual(self.backups_mock.delete.call_count, + len(share_backups)) + self.assertIsNone(result) + + def test_share_backup_delete_exception(self): + arglist = [ + self.share_backup.id + ] + verifylist = [ + ('backup', [self.share_backup.id]) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + self.backups_mock.delete.side_effect = exceptions.CommandError() + self.assertRaises(exceptions.CommandError, + self.cmd.take_action, + parsed_args) + + +class TestShareBackupList(TestShareBackup): + + columns = [ + 'ID', + 'Name', + 'Share ID', + 'Status', + ] + detailed_columns = [ + 'ID', + 'Name', + 'Share ID', + 'Status', + 'Description', + 'Size', + 'Created At', + 'Updated At', + 'Availability Zone', + 'Progress', + 'Restore Progress', + 'Host', + 'Topic', + ] + + def setUp(self): + super(TestShareBackupList, self).setUp() + + self.share = manila_fakes.FakeShare.create_one_share() + self.shares_mock.get.return_value = self.share + self.backups_list = ( + manila_fakes.FakeShareBackup.create_share_backups( + count=2)) + self.backups_mock.list.return_value = self.backups_list + self.values = (oscutils.get_dict_properties( + i._info, self.columns) for i in self.backups_list) + self.detailed_values = (oscutils.get_dict_properties( + i._info, self.detailed_columns) for i in self.backups_list) + + self.cmd = osc_share_backups.ListShareBackup(self.app, None) + + def test_share_backup_list(self): + arglist = [] + verifylist = [] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + self.backups_mock.list.assert_called_with( + detailed=0, + search_opts={ + 'offset': None, 'limit': None, 'name': None, + 'description': None, 'name~': None, 'description~': None, + 'status': None, 'share_id': None + }, + sort_key=None, sort_dir=None + ) + self.assertEqual(self.columns, columns) + self.assertEqual(list(self.values), list(data)) + + def test_share_backup_list_detail(self): + arglist = [ + '--detail' + ] + verifylist = [ + ('detail', True) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + self.backups_mock.list.assert_called_with( + detailed=1, + search_opts={ + 'offset': None, 'limit': None, 'name': None, + 'description': None, 'name~': None, 'description~': None, + 'status': None, 'share_id': None + }, + sort_key=None, sort_dir=None + ) + self.assertEqual(self.detailed_columns, columns) + self.assertEqual(list(self.detailed_values), list(data)) + + def test_share_backup_list_for_share(self): + arglist = [ + '--share', 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.backups_mock.list.assert_called_with( + detailed=0, + search_opts={ + 'offset': None, 'limit': None, 'name': None, + 'description': None, 'name~': None, 'description~': None, + 'status': None, 'share_id': self.share.id + }, + sort_key=None, sort_dir=None + ) + self.assertEqual(self.columns, columns) + self.assertEqual(list(self.values), list(data)) + + +class TestShareBackupShow(TestShareBackup): + + def setUp(self): + super(TestShareBackupShow, self).setUp() + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup() + ) + self.backups_mock.get.return_value = self.share_backup + self.cmd = osc_share_backups.ShowShareBackup(self.app, None) + self.data = tuple(self.share_backup._info.values()) + self.columns = tuple(self.share_backup._info.keys()) + + def test_share_backup_show_missing_args(self): + arglist = [] + verifylist = [] + + self.assertRaises( + osc_utils.ParserException, + self.check_parser, self.cmd, arglist, verifylist) + + def test_share_backup_show(self): + arglist = [ + self.share_backup.id + ] + verifylist = [ + ('backup', self.share_backup.id) + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + self.backups_mock.get.assert_called_with( + self.share_backup.id + ) + + self.assertCountEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + +class TestShareBackupRestore(TestShareBackup): + + def setUp(self): + super(TestShareBackupRestore, self).setUp() + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup() + ) + self.backups_mock.get.return_value = self.share_backup + self.cmd = osc_share_backups.RestoreShareBackup( + self.app, None) + + def test_share_backup_restore(self): + arglist = [ + self.share_backup.id, + ] + verifylist = [ + ('backup', self.share_backup.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + self.backups_mock.restore.assert_called_with(self.share_backup.id) + self.assertIsNone(result) + + +class TestShareBackupSet(TestShareBackup): + + def setUp(self): + super(TestShareBackupSet, self).setUp() + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup() + ) + self.backups_mock.get.return_value = self.share_backup + self.cmd = osc_share_backups.SetShareBackup(self.app, None) + + def test_set_share_backup_name(self): + arglist = [ + self.share_backup.id, + '--name', "FAKE_SHARE_BACKUP_NAME" + ] + verifylist = [ + ('backup', self.share_backup.id), + ('name', "FAKE_SHARE_BACKUP_NAME") + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + self.backups_mock.update.assert_called_with(self.share_backup, + name=parsed_args.name) + self.assertIsNone(result) + + def test_set_backup_status(self): + arglist = [ + self.share_backup.id, + '--status', 'available' + ] + verifylist = [ + ('backup', self.share_backup.id), + ('status', 'available') + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.backups_mock.reset_status.assert_called_with( + self.share_backup, + parsed_args.status) + self.assertIsNone(result) + + +class TestShareBackupUnset(TestShareBackup): + + def setUp(self): + super(TestShareBackupUnset, self).setUp() + + self.share_backup = ( + manila_fakes.FakeShareBackup.create_one_backup() + ) + + self.backups_mock.get.return_value = self.share_backup + self.cmd = osc_share_backups.UnsetShareBackup(self.app, None) + + def test_unset_backup_name(self): + arglist = [ + self.share_backup.id, + '--name' + ] + verifylist = [ + ('backup', self.share_backup.id), + ('name', True) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.backups_mock.update.assert_called_with( + self.share_backup, + name=None) + self.assertIsNone(result) + + def test_unset_backup_description(self): + arglist = [ + self.share_backup.id, + '--description' + ] + verifylist = [ + ('backup', self.share_backup.id), + ('description', True) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.backups_mock.update.assert_called_with( + self.share_backup, + description=None) + self.assertIsNone(result) diff --git a/manilaclient/tests/unit/v2/test_share_backups.py b/manilaclient/tests/unit/v2/test_share_backups.py new file mode 100644 index 000000000..954bd98bb --- /dev/null +++ b/manilaclient/tests/unit/v2/test_share_backups.py @@ -0,0 +1,91 @@ +# 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 + +import ddt + +from manilaclient import api_versions +from manilaclient.tests.unit import utils +from manilaclient.tests.unit.v2 import fakes +from manilaclient.v2 import share_backups + +FAKE_BACKUP = 'fake_backup' + + +@ddt.ddt +class ShareBackupsTest(utils.TestCase): + + class _FakeShareBackup(object): + id = 'fake_share_backup_id' + + def setUp(self): + super(ShareBackupsTest, self).setUp() + microversion = api_versions.APIVersion("2.80") + self.manager = share_backups.ShareBackupManager( + fakes.FakeClient(api_version=microversion)) + + def test_delete_str(self): + with mock.patch.object(self.manager, '_delete', mock.Mock()): + self.manager.delete(FAKE_BACKUP) + self.manager._delete.assert_called_once_with( + share_backups.RESOURCE_PATH % FAKE_BACKUP) + + def test_delete_obj(self): + backup = self._FakeShareBackup + with mock.patch.object(self.manager, '_delete', mock.Mock()): + self.manager.delete(backup) + self.manager._delete.assert_called_once_with( + share_backups.RESOURCE_PATH % backup.id) + + def test_get(self): + with mock.patch.object(self.manager, '_get', mock.Mock()): + self.manager.get(FAKE_BACKUP) + self.manager._get.assert_called_once_with( + share_backups.RESOURCE_PATH % FAKE_BACKUP, + share_backups.RESOURCE_NAME) + + def test_restore(self): + with mock.patch.object(self.manager, '_action', mock.Mock()): + self.manager.restore(FAKE_BACKUP) + self.manager._action.assert_called_once_with( + 'restore', FAKE_BACKUP) + + def test_list(self): + with mock.patch.object(self.manager, '_list', mock.Mock()): + self.manager.list() + self.manager._list.assert_called_once_with( + share_backups.RESOURCES_PATH + '/detail', + share_backups.RESOURCES_NAME) + + def test_list_with_share(self): + with mock.patch.object(self.manager, '_list', mock.Mock()): + self.manager.list(search_opts={'share_id': 'fake_share_id'}) + share_uri = '?share_id=fake_share_id' + self.manager._list.assert_called_once_with( + (share_backups.RESOURCES_PATH + '/detail' + share_uri), + share_backups.RESOURCES_NAME) + + def test_reset_state(self): + with mock.patch.object(self.manager, '_action', mock.Mock()): + self.manager.reset_status(FAKE_BACKUP, 'fake_status') + self.manager._action.assert_called_once_with( + 'reset_status', FAKE_BACKUP, {'status': 'fake_status'}) + + def test_update(self): + backup = self._FakeShareBackup + with mock.patch.object(self.manager, '_update', mock.Mock()): + data = dict(name='backup1') + self.manager.update(backup, **data) + self.manager._update.assert_called_once_with( + share_backups.RESOURCE_PATH % backup.id, + {'share_backup': data}) diff --git a/manilaclient/v2/client.py b/manilaclient/v2/client.py index 036ae6e4e..d0ce3998a 100644 --- a/manilaclient/v2/client.py +++ b/manilaclient/v2/client.py @@ -28,6 +28,7 @@ from manilaclient.v2 import scheduler_stats from manilaclient.v2 import security_services from manilaclient.v2 import services from manilaclient.v2 import share_access_rules +from manilaclient.v2 import share_backups from manilaclient.v2 import share_export_locations from manilaclient.v2 import share_group_snapshots from manilaclient.v2 import share_group_type_access @@ -237,6 +238,7 @@ class Client(object): self.pools = scheduler_stats.PoolManager(self) self.share_access_rules = ( share_access_rules.ShareAccessRuleManager(self)) + self.share_backups = share_backups.ShareBackupManager(self) self._load_extensions(extensions) diff --git a/manilaclient/v2/share_backups.py b/manilaclient/v2/share_backups.py new file mode 100644 index 000000000..3f2bccc85 --- /dev/null +++ b/manilaclient/v2/share_backups.py @@ -0,0 +1,137 @@ +# 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 + +RESOURCES_NAME = 'share_backups' +RESOURCE_NAME = 'share_backup' +RESOURCES_PATH = '/share-backups' +RESOURCE_PATH = '/share-backups/%s' +RESOURCE_PATH_ACTION = '/share-backups/%s/action' + + +class ShareBackup(base.Resource): + def __repr__(self): + return "" % self.id + + +class ShareBackupManager(base.ManagerWithFind): + """Manage :class:`ShareBackup` resources.""" + resource_class = ShareBackup + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def get(self, backup): + """Get a share backup. + + :param backup: either backup object or its UUID. + :rtype: :class:`ShareBackup` + """ + backup_id = base.getid(backup) + return self._get(RESOURCE_PATH % backup_id, RESOURCE_NAME) + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def list(self, detailed=True, search_opts=None, sort_key=None, + sort_dir=None): + """List all share backups or list backups belonging to a share. + + :param detailed: list backups with detailed fields. + :param search_opts: Search options to filter out shares. + :param sort_key: Key to be sorted. + :param sort_dir: Sort direction, should be 'desc' or 'asc'. + :rtype: list of :class:`ShareBackup` + """ + + search_opts = search_opts or {} + + if sort_key is not None: + if sort_key in constants.BACKUP_SORT_KEY_VALUES: + search_opts['sort_key'] = sort_key + else: + raise ValueError( + 'sort_key must be one of the following: %s.' + % ', '.join(constants.BACKUP_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-backups/detail%s" % (query_string,) + else: + path = "/share-backups%s" % (query_string,) + + return self._list(path, 'share_backups') + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def create(self, share, backup_options=None, description=None, name=None): + """Create a backup for a share. + + :param share: The share to create the backup of. Can be the share + object or its UUID. + :param backup_options: dict - custom set of key-values + :param name: text - name of new share + :param description: - description for new share + """ + share_id = base.getid(share) + body = { + 'share_id': share_id, + 'backup_options': backup_options, + 'description': description, + 'name': name, + } + + return self._create(RESOURCES_PATH, + {RESOURCE_NAME: body}, + RESOURCE_NAME) + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def delete(self, backup): + backup_id = base.getid(backup) + url = RESOURCE_PATH % backup_id + self._delete(url) + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def restore(self, backup): + return self._action('restore', backup) + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def reset_status(self, backup, state): + return self._action('reset_status', backup, {"status": state}) + + @api_versions.wraps("2.80") + @api_versions.experimental_api + def update(self, backup, **kwargs): + if not kwargs: + return + backup_id = base.getid(backup) + body = {'share_backup': kwargs} + return self._update(RESOURCE_PATH % backup_id, body) + + def _action(self, action, backup, info=None, **kwargs): + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + backup_id = base.getid(backup) + url = RESOURCE_PATH_ACTION % backup_id + return self.api.client.post(url, body=body) diff --git a/releasenotes/notes/share-backup-98e11c6a28897e94.yaml b/releasenotes/notes/share-backup-98e11c6a28897e94.yaml new file mode 100644 index 000000000..12512f6fc --- /dev/null +++ b/releasenotes/notes/share-backup-98e11c6a28897e94.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Added support for share backup APIs in the SDK and the openstackclient + plugin. You can use the openstack client to create a backup, restore a + backup, delete a backup, list backups with filters, and update the name + and description fields of a backup. Available from microversion 2.80. diff --git a/setup.cfg b/setup.cfg index 4a91b0f94..b3d9db7bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -59,6 +59,13 @@ openstack.share.v2 = share_access_show = manilaclient.osc.v2.share_access_rules:ShowShareAccess share_access_set = manilaclient.osc.v2.share_access_rules:SetShareAccess share_access_unset = manilaclient.osc.v2.share_access_rules:UnsetShareAccess + share_backup_create = manilaclient.osc.v2.share_backups:CreateShareBackup + share_backup_delete = manilaclient.osc.v2.share_backups:DeleteShareBackup + share_backup_list = manilaclient.osc.v2.share_backups:ListShareBackup + share_backup_show = manilaclient.osc.v2.share_backups:ShowShareBackup + share_backup_restore = manilaclient.osc.v2.share_backups:RestoreShareBackup + share_backup_set = manilaclient.osc.v2.share_backups:SetShareBackup + share_backup_unset = manilaclient.osc.v2.share_backups:UnsetShareBackup share_type_create = manilaclient.osc.v2.share_types:CreateShareType share_type_delete = manilaclient.osc.v2.share_types:DeleteShareType share_type_set = manilaclient.osc.v2.share_types:SetShareType