From 6806fcfbbe0e996d7d0a6ad82663c13821fdf77d Mon Sep 17 00:00:00 2001 From: Jordan Pittier Date: Fri, 7 Oct 2016 16:40:09 +0200 Subject: [PATCH] Implement create/get/list/delete volume backups Code should me mostly straightforward. Things are done for backups as they are done for snapshots. Change-Id: I450356274916e9abef80f270b8a15f94fac6692b --- ...lume_backups_support-6f7ceab440853833.yaml | 4 + shade/_tasks.py | 15 +++ shade/openstackcloud.py | 115 ++++++++++++++++++ shade/tests/base.py | 7 +- shade/tests/functional/test_volume_backup.py | 72 +++++++++++ shade/tests/unit/test_volume_backups.py | 72 +++++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/cinder_volume_backups_support-6f7ceab440853833.yaml create mode 100644 shade/tests/functional/test_volume_backup.py create mode 100644 shade/tests/unit/test_volume_backups.py diff --git a/releasenotes/notes/cinder_volume_backups_support-6f7ceab440853833.yaml b/releasenotes/notes/cinder_volume_backups_support-6f7ceab440853833.yaml new file mode 100644 index 000000000..380b653f4 --- /dev/null +++ b/releasenotes/notes/cinder_volume_backups_support-6f7ceab440853833.yaml @@ -0,0 +1,4 @@ +--- +features: + - Add support for Cinder volume backup resources, with the + usual methods (search/list/get/create/delete). diff --git a/shade/_tasks.py b/shade/_tasks.py index 09f1c78b6..5b894e73d 100644 --- a/shade/_tasks.py +++ b/shade/_tasks.py @@ -409,6 +409,21 @@ class VolumeSnapshotList(task_manager.Task): return client.cinder_client.volume_snapshots.list(**self.args) +class VolumeBackupList(task_manager.Task): + def main(self, client): + return client.cinder_client.backups.list(**self.args) + + +class VolumeBackupCreate(task_manager.Task): + def main(self, client): + return client.cinder_client.backups.create(**self.args) + + +class VolumeBackupDelete(task_manager.Task): + def main(self, client): + return client.cinder_client.backups.delete(**self.args) + + class VolumeSnapshotDelete(task_manager.Task): def main(self, client): return client.cinder_client.volume_snapshots.delete(**self.args) diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index f9dff4bed..94e85ba96 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -1321,6 +1321,11 @@ class OpenStackCloud(_normalize.Normalizer): return _utils._filter_list( volumesnapshots, name_or_id, filters) + def search_volume_backups(self, name_or_id=None, filters=None): + volume_backups = self.list_volume_backups() + return _utils._filter_list( + volume_backups, name_or_id, filters) + def search_flavors(self, name_or_id=None, filters=None, get_extra=True): flavors = self.list_flavors(get_extra=get_extra) return _utils._filter_list(flavors, name_or_id, filters) @@ -3520,6 +3525,63 @@ class OpenStackCloud(_normalize.Normalizer): return _utils._get_entity(self.search_volume_snapshots, name_or_id, filters) + def create_volume_backup(self, volume_id, name=None, description=None, + force=False, wait=True, timeout=None): + """Create a volume backup. + + :param volume_id: the id of the volume to backup. + :param name: name of the backup, one will be generated if one is + not provided + :param description: description of the backup, one will be generated + if one is not provided + :param force: If set to True the backup will be created even if the + volume is attached to an instance, if False it will not + :param wait: If true, waits for volume backup to be created. + :param timeout: Seconds to wait for volume backup creation. None is + forever. + + :returns: The created volume backup object. + + :raises: OpenStackCloudTimeout if wait time exceeded. + :raises: OpenStackCloudException on operation error. + """ + with _utils.shade_exceptions( + "Error creating backup of volume {volume_id}".format( + volume_id=volume_id)): + backup = self.manager.submit_task( + _tasks.VolumeBackupCreate( + volume_id=volume_id, name=name, description=description, + force=force + ) + ) + + if wait: + backup_id = backup['id'] + msg = ("Timeout waiting for the volume backup {} to be " + "available".format(backup_id)) + for _ in _utils._iterate_timeout(timeout, msg): + backup = self.get_volume_backup(backup_id) + + if backup['status'] == 'available': + break + + if backup['status'] == 'error': + msg = ("Error in creating volume " + "backup {}, please check logs".format(backup_id)) + raise OpenStackCloudException(msg) + + return backup + + def get_volume_backup(self, name_or_id, filters=None): + """Get a volume backup by name or ID. + + :returns: A backup ``munch.Munch`` or None if no matching backup is + found. + + """ + return _utils._get_entity(self.search_volume_backups, name_or_id, + filters) + def list_volume_snapshots(self, detailed=True, search_opts=None): """List all volume snapshots. @@ -3532,6 +3594,59 @@ class OpenStackCloud(_normalize.Normalizer): _tasks.VolumeSnapshotList( detailed=detailed, search_opts=search_opts))) + def list_volume_backups(self, detailed=True, search_opts=None): + """ + List all volume backups. + + :param bool detailed: Also list details for each entry + :param dict search_opts: Search options + A dictionary of meta data to use for further filtering. Example:: + { + 'name': 'my-volume-backup', + 'status': 'available', + 'volume_id': 'e126044c-7b4c-43be-a32a-c9cbbc9ddb56', + 'all_tenants': 1 + } + :returns: A list of volume backups ``munch.Munch``. + """ + with _utils.shade_exceptions("Error getting a list of backups"): + return self.manager.submit_task( + _tasks.VolumeBackupList( + detailed=detailed, search_opts=search_opts)) + + def delete_volume_backup(self, name_or_id=None, force=False, wait=False, + timeout=None): + """Delete a volume backup. + + :param name_or_id: Name or unique ID of the volume backup. + :param force: Allow delete in state other than error or available. + :param wait: If true, waits for volume backup to be deleted. + :param timeout: Seconds to wait for volume backup deletion. None is + forever. + + :raises: OpenStackCloudTimeout if wait time exceeded. + :raises: OpenStackCloudException on operation error. + """ + + volume_backup = self.get_volume_backup(name_or_id) + + if not volume_backup: + return False + + with _utils.shade_exceptions("Error in deleting volume backup"): + self.manager.submit_task( + _tasks.VolumeBackupDelete( + backup=volume_backup['id'], force=force + ) + ) + if wait: + msg = "Timeout waiting for the volume backup to be deleted." + for count in _utils._iterate_timeout(timeout, msg): + if not self.get_volume_backup(volume_backup['id']): + break + + return True + def delete_volume_snapshot(self, name_or_id=None, wait=False, timeout=None): """Delete a volume snapshot. diff --git a/shade/tests/base.py b/shade/tests/base.py index 382006ca1..3de6f0bd1 100644 --- a/shade/tests/base.py +++ b/shade/tests/base.py @@ -29,13 +29,16 @@ class TestCase(testtools.TestCase): """Test case base class for all tests.""" + # A way to adjust slow test classes + TIMEOUT_SCALING_FACTOR = 1.0 + def setUp(self): """Run before each test method to initialize test environment.""" super(TestCase, self).setUp() - test_timeout = os.environ.get('OS_TEST_TIMEOUT', 0) + test_timeout = int(os.environ.get('OS_TEST_TIMEOUT', 0)) try: - test_timeout = int(test_timeout) + test_timeout = int(test_timeout * self.TIMEOUT_SCALING_FACTOR) except ValueError: # If timeout value is invalid do not set a timeout. test_timeout = 0 diff --git a/shade/tests/functional/test_volume_backup.py b/shade/tests/functional/test_volume_backup.py new file mode 100644 index 000000000..5ea4ef0fd --- /dev/null +++ b/shade/tests/functional/test_volume_backup.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# 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 shade.tests.functional import base + + +class TestVolume(base.BaseFunctionalTestCase): + # Creating a volume backup is incredibly slow. + TIMEOUT_SCALING_FACTOR = 1.5 + + def setUp(self): + super(TestVolume, self).setUp() + if not self.demo_cloud.has_service('volume'): + self.skipTest('volume service not supported by cloud') + + def test_create_get_delete_volume_backup(self): + volume = self.demo_cloud.create_volume( + display_name=self.getUniqueString(), size=1) + self.addCleanup(self.demo_cloud.delete_volume, volume['id']) + + backup_name_1 = self.getUniqueString() + backup_desc_1 = self.getUniqueString() + backup = self.demo_cloud.create_volume_backup( + volume_id=volume['id'], name=backup_name_1, + description=backup_desc_1, wait=True) + self.assertEqual(backup_name_1, backup['name']) + + backup = self.demo_cloud.get_volume_backup(backup['id']) + self.assertEqual("available", backup['status']) + self.assertEqual(backup_desc_1, backup['description']) + + self.demo_cloud.delete_volume_backup(backup['id'], wait=True) + self.assertIsNone(self.demo_cloud.get_volume_backup(backup['id'])) + + def test_list_volume_backups(self): + vol1 = self.demo_cloud.create_volume( + display_name=self.getUniqueString(), size=1) + self.addCleanup(self.demo_cloud.delete_volume, vol1['id']) + + # We create 2 volumes to create 2 backups. We could have created 2 + # backups from the same volume but taking 2 successive backups seems + # to be race-condition prone. And I didn't want to use an ugly sleep() + # here. + vol2 = self.demo_cloud.create_volume( + display_name=self.getUniqueString(), size=1) + self.addCleanup(self.demo_cloud.delete_volume, vol2['id']) + + backup_name_1 = self.getUniqueString() + backup = self.demo_cloud.create_volume_backup( + volume_id=vol1['id'], name=backup_name_1) + self.addCleanup(self.demo_cloud.delete_volume_backup, backup['id']) + + backup = self.demo_cloud.create_volume_backup(volume_id=vol2['id']) + self.addCleanup(self.demo_cloud.delete_volume_backup, backup['id']) + + backups = self.demo_cloud.list_volume_backups() + self.assertEqual(2, len(backups)) + + backups = self.demo_cloud.list_volume_backups( + search_opts={"name": backup_name_1}) + self.assertEqual(1, len(backups)) + self.assertEqual(backup_name_1, backups[0]['name']) diff --git a/shade/tests/unit/test_volume_backups.py b/shade/tests/unit/test_volume_backups.py new file mode 100644 index 000000000..4121ad462 --- /dev/null +++ b/shade/tests/unit/test_volume_backups.py @@ -0,0 +1,72 @@ +# 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 mock + +import shade +from shade.tests.unit import base + + +class TestVolumeBackups(base.TestCase): + @mock.patch.object(shade.OpenStackCloud, 'list_volume_backups') + @mock.patch("shade._utils._filter_list") + def test_search_volume_backups(self, m_filter_list, m_list_volume_backups): + result = self.cloud.search_volume_backups( + mock.sentinel.name_or_id, mock.sentinel.filter) + + m_list_volume_backups.assert_called_once_with() + m_filter_list.assert_called_once_with( + m_list_volume_backups.return_value, mock.sentinel.name_or_id, + mock.sentinel.filter) + self.assertIs(m_filter_list.return_value, result) + + @mock.patch("shade._utils._get_entity") + def test_get_volume_backup(self, m_get_entity): + result = self.cloud.get_volume_backup( + mock.sentinel.name_or_id, mock.sentinel.filter) + + self.assertIs(m_get_entity.return_value, result) + m_get_entity.assert_called_once_with( + self.cloud.search_volume_backups, mock.sentinel.name_or_id, + mock.sentinel.filter) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + def test_list_volume_backups(self, m_cinder_client): + backup_id = '6ff16bdf-44d5-4bf9-b0f3-687549c76414' + m_cinder_client.backups.list.return_value = [ + {'id': backup_id} + ] + result = self.cloud.list_volume_backups( + mock.sentinel.detailed, mock.sentinel.search_opts) + + m_cinder_client.backups.list.assert_called_once_with( + detailed=mock.sentinel.detailed, + search_opts=mock.sentinel.search_opts) + self.assertEqual(backup_id, result[0]['id']) + + @mock.patch.object(shade.OpenStackCloud, 'cinder_client') + @mock.patch("shade._utils._iterate_timeout") + @mock.patch.object(shade.OpenStackCloud, 'get_volume_backup') + def test_delete_volume_backup(self, m_get_volume_backup, + m_iterate_timeout, m_cinder_client): + m_get_volume_backup.side_effect = [{'id': 42}, True, False] + self.cloud.delete_volume_backup( + mock.sentinel.name_or_id, mock.sentinel.force, mock.sentinel.wait, + mock.sentinel.timeout) + + m_iterate_timeout.assert_called_once_with( + mock.sentinel.timeout, mock.ANY) + m_cinder_client.backups.delete.assert_called_once_with( + backup=42, force=mock.sentinel.force) + + # We expect 3 calls, the last return_value is False which breaks the + # wait loop. + m_get_volume_backup.call_args_list = [mock.call(42)] * 3