diff --git a/manila_tempest_tests/common/waiters.py b/manila_tempest_tests/common/waiters.py index 11a02d35..a11b620d 100644 --- a/manila_tempest_tests/common/waiters.py +++ b/manila_tempest_tests/common/waiters.py @@ -60,6 +60,7 @@ def wait_for_resource_status(client, resource_id, status, 'share_group': 'get_share_group', 'share_group_snapshot': 'get_share_group_snapshot', 'share_replica': 'get_share_replica', + 'share_backup': 'get_share_backup' } action_name = get_resource_action[resource_name] diff --git a/manila_tempest_tests/config.py b/manila_tempest_tests/config.py index 4d36944c..87b53e37 100644 --- a/manila_tempest_tests/config.py +++ b/manila_tempest_tests/config.py @@ -265,6 +265,9 @@ ShareGroup = [ default=False, help="Enable or disable migration with " "preserve_snapshots tests set to True."), + cfg.BoolOpt("run_driver_assisted_backup_tests", + default=False, + help="Enable or disable share backup tests."), cfg.BoolOpt("run_manage_unmanage_tests", default=False, help="Defines whether to run manage/unmanage tests or not. " @@ -314,6 +317,10 @@ ShareGroup = [ default=1500, help="Time to wait for share migration before " "timing out (seconds)."), + cfg.IntOpt("share_backup_timeout", + default=1500, + help="Time to wait for share backup before " + "timing out (seconds)."), cfg.IntOpt("share_server_migration_timeout", default="1500", help="Time to wait for share server migration before " @@ -349,4 +356,7 @@ ShareGroup = [ "writing data from /dev/zero might not yield significant " "space savings as these systems are already optimized for " "efficient compression."), + cfg.DictOpt("driver_assisted_backup_test_driver_options", + default={'dummy': True}, + help="Share backup driver options specified as dict."), ] diff --git a/manila_tempest_tests/services/share/json/shares_client.py b/manila_tempest_tests/services/share/json/shares_client.py index 6871edae..fb03afca 100644 --- a/manila_tempest_tests/services/share/json/shares_client.py +++ b/manila_tempest_tests/services/share/json/shares_client.py @@ -323,6 +323,9 @@ class SharesClient(rest_client.RestClient): elif "server_id" in kwargs: return self._is_resource_deleted( self.show_share_server, kwargs.get("server_id")) + elif "backup_id" in kwargs: + return self._is_resource_deleted( + self.get_share_backup, kwargs.get("backup_id")) else: raise share_exceptions.InvalidResource( message=str(kwargs)) diff --git a/manila_tempest_tests/services/share/v2/json/shares_client.py b/manila_tempest_tests/services/share/v2/json/shares_client.py index f4538d78..d72c0980 100644 --- a/manila_tempest_tests/services/share/v2/json/shares_client.py +++ b/manila_tempest_tests/services/share/v2/json/shares_client.py @@ -1836,6 +1836,109 @@ class SharesV2Client(shares_client.SharesClient): body = json.loads(body) return rest_client.ResponseBody(resp, body) +############### + + def get_share_backup(self, backup_id, version=LATEST_MICROVERSION): + """Returns the details of a single backup.""" + resp, body = self.get("share-backups/%s" % backup_id, + headers=EXPERIMENTAL, + extra_headers=True, + version=version) + self.expected_success(200, resp.status) + body = json.loads(body) + return rest_client.ResponseBody(resp, body) + + def list_share_backups(self, share_id=None, version=LATEST_MICROVERSION): + """Get list of backups.""" + uri = "share-backups/detail" + if share_id: + uri += (f'?share_id={share_id}') + resp, body = self.get(uri, headers=EXPERIMENTAL, + extra_headers=True, version=version) + self.expected_success(200, resp.status) + body = json.loads(body) + return rest_client.ResponseBody(resp, body) + + def create_share_backup(self, share_id, name=None, description=None, + backup_options=None, version=LATEST_MICROVERSION): + """Create a share backup.""" + if name is None: + name = data_utils.rand_name("tempest-created-share-backup") + if description is None: + description = data_utils.rand_name( + "tempest-created-share-backup-desc") + post_body = { + 'share_backup': { + 'name': name, + 'description': description, + 'share_id': share_id, + 'backup_options': backup_options, + } + } + body = json.dumps(post_body) + resp, body = self.post('share-backups', body, + headers=EXPERIMENTAL, + extra_headers=True, + version=version) + + self.expected_success(202, resp.status) + body = json.loads(body) + return rest_client.ResponseBody(resp, body) + + def delete_share_backup(self, backup_id, version=LATEST_MICROVERSION): + """Delete share backup.""" + uri = "share-backups/%s" % backup_id + resp, body = self.delete(uri, + headers=EXPERIMENTAL, + extra_headers=True, + version=version) + self.expected_success(202, resp.status) + return rest_client.ResponseBody(resp, body) + + def restore_share_backup(self, backup_id, version=LATEST_MICROVERSION): + """Restore share backup.""" + uri = "share-backups/%s/action" % backup_id + body = {'restore': None} + resp, body = self.post(uri, json.dumps(body), + headers=EXPERIMENTAL, + extra_headers=True, + version=version) + self.expected_success(202, resp.status) + body = json.loads(body) + return rest_client.ResponseBody(resp, body) + + def update_share_backup(self, backup_id, name=None, description=None, + version=LATEST_MICROVERSION): + """Update share backup.""" + uri = "share-backups/%s" % backup_id + post_body = {} + if name: + post_body['name'] = name + if description: + post_body['description'] = description + + body = json.dumps({'share_backup': post_body}) + resp, body = self.put(uri, body, + headers=EXPERIMENTAL, + extra_headers=True, + version=version) + self.expected_success(200, resp.status) + body = json.loads(body) + return rest_client.ResponseBody(resp, body) + + def reset_state_share_backup(self, backup_id, + status=constants.STATUS_AVAILABLE, + version=LATEST_MICROVERSION): + + uri = "share-backups/%s/action" % backup_id + body = {'reset_status': {'status': status}} + resp, body = self.post(uri, json.dumps(body), + headers=EXPERIMENTAL, + extra_headers=True, + version=LATEST_MICROVERSION) + self.expected_success(202, resp.status) + return rest_client.ResponseBody(resp, body) + ################ def create_snapshot_access_rule(self, snapshot_id, access_type="ip", diff --git a/manila_tempest_tests/share_exceptions.py b/manila_tempest_tests/share_exceptions.py index efa61b5c..5b5ca18a 100644 --- a/manila_tempest_tests/share_exceptions.py +++ b/manila_tempest_tests/share_exceptions.py @@ -86,3 +86,7 @@ class ShareServerBuildErrorException(exceptions.TempestException): class ShareServerMigrationException(exceptions.TempestException): message = ("Share server %(server_id)s failed to migrate and is in ERROR " "status") + + +class ShareBackupException(exceptions.TempestException): + message = ("Share backup %(backup_id)s failed and is in ERROR status") diff --git a/manila_tempest_tests/tests/api/base.py b/manila_tempest_tests/tests/api/base.py index 18562e50..cb64ec80 100755 --- a/manila_tempest_tests/tests/api/base.py +++ b/manila_tempest_tests/tests/api/base.py @@ -704,6 +704,31 @@ class BaseSharesTest(test.BaseTestCase): resource_name='share_replica') return replica + @classmethod + def create_backup_wait_for_active(cls, share_id, client=None, + cleanup_in_class=False, cleanup=True, + version=CONF.share.max_api_microversion): + client = client or cls.shares_v2_client + backup_name = data_utils.rand_name('Backup') + backup_options = CONF.share.driver_assisted_backup_test_driver_options + backup = client.create_share_backup( + share_id, + name=backup_name, + backup_options=backup_options)['share_backup'] + resource = { + "type": "share_backup", + "id": backup["id"], + "client": client, + } + if cleanup: + if cleanup_in_class: + cls.class_resources.insert(0, resource) + else: + cls.method_resources.insert(0, resource) + waiters.wait_for_resource_status(client, backup["id"], "available", + resource_name='share_backup') + return client.get_share_backup(backup['id'])['share_backup'] + @classmethod def delete_share_replica(cls, replica_id, client=None, version=CONF.share.max_api_microversion): @@ -919,6 +944,9 @@ class BaseSharesTest(test.BaseTestCase): elif res["type"] == "share_replica": client.delete_share_replica(res_id) client.wait_for_resource_deletion(replica_id=res_id) + elif res["type"] == "share_backup": + client.delete_share_backup(res_id) + client.wait_for_resource_deletion(backup_id=res_id) elif res["type"] == "share_network_subnet": sn_id = res["extra_params"]["share_network_id"] client.delete_subnet(sn_id, res_id) diff --git a/manila_tempest_tests/tests/api/test_backup.py b/manila_tempest_tests/tests/api/test_backup.py new file mode 100644 index 00000000..f4438025 --- /dev/null +++ b/manila_tempest_tests/tests/api/test_backup.py @@ -0,0 +1,117 @@ +# Copyright 2024 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. + +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc +from testtools import testcase as tc + +from manila_tempest_tests.common import waiters +from manila_tempest_tests.tests.api import base +from manila_tempest_tests import utils + + +CONF = config.CONF +_MIN_SUPPORTED_MICROVERSION = '2.80' + + +class ShareBackupTest(base.BaseSharesMixedTest): + + @classmethod + def skip_checks(cls): + super(ShareBackupTest, cls).skip_checks() + if not CONF.share.run_driver_assisted_backup_tests: + raise cls.skipException("Share backup tests are disabled.") + utils.check_skip_if_microversion_not_supported( + _MIN_SUPPORTED_MICROVERSION) + + def setUp(self): + super(ShareBackupTest, self).setUp() + extra_specs = { + 'snapshot_support': True, + 'mount_snapshot_support': True, + } + share_type = self.create_share_type(extra_specs=extra_specs) + share = self.create_share(self.shares_v2_client.share_protocol, + share_type_id=share_type['id']) + self.share_id = share["id"] + + @decorators.idempotent_id('12c36c97-faf4-4fec-9a9b-7cff0d2035cd') + @tc.attr(base.TAG_POSITIVE, base.TAG_BACKEND) + def test_create_share_backup(self): + backup = self.create_backup_wait_for_active(self.share_id) + + # Verify backup create API response + expected_keys = ["id", "share_id", "status", + "availability_zone", "created_at", "updated_at", + "size", "progress", "restore_progress", + "name", "description"] + + # Strict key check + actual_backup = self.shares_v2_client.get_share_backup( + backup['id'])['share_backup'] + actual_keys = actual_backup.keys() + self.assertEqual(backup['id'], actual_backup['id']) + self.assertEqual(set(expected_keys), set(actual_keys)) + + @decorators.idempotent_id('34c36c97-faf4-4fec-9a9b-7cff0d2035cd') + @tc.attr(base.TAG_POSITIVE, base.TAG_BACKEND) + def test_delete_share_backup(self): + backup = self.create_backup_wait_for_active( + self.share_id, cleanup=False) + + # Delete share backup + self.shares_v2_client.delete_share_backup(backup['id']) + self.shares_v2_client.wait_for_resource_deletion( + backup_id=backup['id']) + self.assertRaises( + lib_exc.NotFound, + self.shares_v2_client.get_share_backup, + backup['id']) + + @decorators.idempotent_id('56c36c97-faf4-4fec-9a9b-7cff0d2035cd') + @tc.attr(base.TAG_POSITIVE, base.TAG_BACKEND) + def test_restore_share_backup(self): + backup = self.create_backup_wait_for_active(self.share_id) + + # Restore share backup + restore = self.shares_v2_client.restore_share_backup( + backup['id'])['restore'] + waiters.wait_for_resource_status( + self.shares_v2_client, backup['id'], 'available', + resource_name='share_backup') + + self.assertEqual(restore['share_id'], self.share_id) + + @decorators.idempotent_id('78c36c97-faf4-4fec-9a9b-7cff0d2035cd') + @tc.attr(base.TAG_POSITIVE, base.TAG_BACKEND) + def test_update_share_backup(self): + backup = self.create_backup_wait_for_active(self.share_id) + + # Update share backup name + backup_name2 = data_utils.rand_name('Backup') + backup = self.shares_v2_client.update_share_backup( + backup['id'], name=backup_name2)['share_backup'] + updated_backup = self.shares_v2_client.get_share_backup( + backup['id'])['share_backup'] + self.assertEqual(backup_name2, updated_backup['name']) + + @decorators.idempotent_id('19c36c97-faf4-4fec-9a9b-7cff0d2045af') + @tc.attr(base.TAG_POSITIVE, base.TAG_BACKEND) + def test_list_share_backups(self): + self.create_backup_wait_for_active(self.share_id) + backups = self.shares_v2_client.list_share_backups() + self.assertEqual(1, len(backups)) diff --git a/manila_tempest_tests/tests/api/test_backup_negative.py b/manila_tempest_tests/tests/api/test_backup_negative.py new file mode 100644 index 00000000..743c1950 --- /dev/null +++ b/manila_tempest_tests/tests/api/test_backup_negative.py @@ -0,0 +1,153 @@ +# Copyright 2024 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. + +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc +from testtools import testcase as tc + +from manila_tempest_tests.common import waiters +from manila_tempest_tests.tests.api import base +from manila_tempest_tests import utils + + +CONF = config.CONF +_MIN_SUPPORTED_MICROVERSION = '2.80' + + +class ShareBackupNegativeTest(base.BaseSharesMixedTest): + + @classmethod + def skip_checks(cls): + super(ShareBackupNegativeTest, cls).skip_checks() + if not CONF.share.run_driver_assisted_backup_tests: + raise cls.skipException("Share backup tests are disabled.") + utils.check_skip_if_microversion_not_supported( + _MIN_SUPPORTED_MICROVERSION) + + def setUp(self): + super(ShareBackupNegativeTest, self).setUp() + extra_specs = { + 'snapshot_support': True, + 'mount_snapshot_support': True, + } + share_type = self.create_share_type(extra_specs=extra_specs) + share = self.create_share(self.shares_v2_client.share_protocol, + share_type_id=share_type['id']) + self.share_id = share["id"] + self.backup_options = ( + CONF.share.driver_assisted_backup_test_driver_options) + + @decorators.idempotent_id('58c36c97-faf4-4fec-9a9b-7cff0d2035ab') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_create_backup_when_share_is_in_backup_creating_state(self): + backup_name1 = data_utils.rand_name('Backup') + backup1 = self.shares_v2_client.create_share_backup( + self.share_id, + name=backup_name1, + backup_options=self.backup_options)['share_backup'] + + # try create backup when share state is busy + backup_name2 = data_utils.rand_name('Backup') + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.create_share_backup, + self.share_id, + name=backup_name2, + backup_options=self.backup_options) + waiters.wait_for_resource_status( + self.shares_v2_client, backup1['id'], "available", + resource_name='share_backup') + + # delete the share backup + self.shares_v2_client.delete_share_backup(backup1['id']) + self.shares_v2_client.wait_for_resource_deletion( + backup_id=backup1['id']) + + @decorators.idempotent_id('58c36c97-faf4-4fec-9a9b-7cff0d2012ab') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_create_backup_when_share_is_in_error_state(self): + self.admin_shares_v2_client.reset_state(self.share_id, + status='error') + + # try create backup when share is not available + backup_name = data_utils.rand_name('Backup') + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.create_share_backup, + self.share_id, + name=backup_name, + backup_options=self.backup_options) + + self.admin_shares_v2_client.reset_state(self.share_id, + status='available') + + @decorators.idempotent_id('58c36c97-faf4-4fec-9a9b-7cff0d2012de') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_create_backup_when_share_has_snapshots(self): + self.create_snapshot_wait_for_active(self.share_id, + cleanup_in_class=False) + + # try create backup when share has snapshots + backup_name = data_utils.rand_name('Backup') + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.create_share_backup, + self.share_id, + name=backup_name, + backup_options=self.backup_options) + + @decorators.idempotent_id('58c12c97-faf4-4fec-9a9b-7cff0d2012de') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_delete_backup_when_backup_is_not_available(self): + backup = self.create_backup_wait_for_active(self.share_id) + self.admin_shares_v2_client.reset_state_share_backup( + backup['id'], status='creating') + + # try delete backup when share backup is not available + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.delete_share_backup, + backup['id']) + + self.admin_shares_v2_client.reset_state_share_backup( + backup['id'], status='available') + + @decorators.idempotent_id('58c56c97-faf4-4fec-9a9b-7cff0d2012de') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_restore_backup_when_share_is_not_available(self): + backup = self.create_backup_wait_for_active(self.share_id) + self.admin_shares_v2_client.reset_state(self.share_id, + status='error') + + # try restore backup when share is not available + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.restore_share_backup, + backup['id']) + + self.admin_shares_v2_client.reset_state(self.share_id, + status='available') + + @decorators.idempotent_id('58c12998-faf4-4fec-9a9b-7cff0d2012de') + @tc.attr(base.TAG_NEGATIVE, base.TAG_BACKEND) + def test_restore_backup_when_backup_is_not_available(self): + backup = self.create_backup_wait_for_active(self.share_id) + self.admin_shares_v2_client.reset_state_share_backup( + backup['id'], status='creating') + + # try restore backup when backup is not available + self.assertRaises(lib_exc.BadRequest, + self.shares_v2_client.restore_share_backup, + backup['id']) + + self.admin_shares_v2_client.reset_state_share_backup( + backup['id'], status='available') diff --git a/zuul.d/manila-tempest-jobs.yaml b/zuul.d/manila-tempest-jobs.yaml index c23801e5..702dcf81 100644 --- a/zuul.d/manila-tempest-jobs.yaml +++ b/zuul.d/manila-tempest-jobs.yaml @@ -626,6 +626,8 @@ MANILA_OPTGROUP_membernet_standalone_network_plugin_mask: 24 MANILA_OPTGROUP_membernet_standalone_network_plugin_network_type: vlan MANILA_OPTGROUP_membernet_standalone_network_plugin_segmentation_id: 1010 + MANILA_CREATE_BACKUP_CONTINUE_TASK_INTERVAL: 30 + MANILA_RESTORE_BACKUP_CONTINUE_TASK_INTERVAL: 30 devstack_local_conf: test-config: "$TEMPEST_CONFIG": @@ -639,6 +641,7 @@ enable_user_rules_for_protocols: cifs multi_backend: true multitenancy_enabled: false + run_driver_assisted_backup_tests: true run_driver_assisted_migration_tests: true run_manage_unmanage_snapshot_tests: true run_manage_unmanage_tests: true