From 37a1decac136dff98149050e871dc7e159caa2e5 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 16 Nov 2018 10:20:56 +0100 Subject: [PATCH] implement block-storage backup resource implement Backup resource with respective functionality of the block-storage.v2 Change-Id: Ie8676bba91fd2236b7f04b3f4d0e72d79a3f3925 --- doc/source/user/proxies/block_storage.rst | 11 ++ .../user/resources/block_storage/index.rst | 1 + .../resources/block_storage/v2/backup.rst | 21 +++ openstack/block_storage/v2/_proxy.py | 103 +++++++++++++++ openstack/block_storage/v2/backup.py | 100 +++++++++++++++ .../block_storage/v2/test_backup.py | 68 ++++++++++ .../unit/block_storage/v2/test_backup.py | 121 ++++++++++++++++++ .../tests/unit/block_storage/v2/test_proxy.py | 72 +++++++++++ ...block-storage-backup-5886e91fd6e423bf.yaml | 3 + 9 files changed, 500 insertions(+) create mode 100644 doc/source/user/resources/block_storage/v2/backup.rst create mode 100644 openstack/block_storage/v2/backup.py create mode 100644 openstack/tests/functional/block_storage/v2/test_backup.py create mode 100644 openstack/tests/unit/block_storage/v2/test_backup.py create mode 100644 releasenotes/notes/block-storage-backup-5886e91fd6e423bf.yaml diff --git a/doc/source/user/proxies/block_storage.rst b/doc/source/user/proxies/block_storage.rst index cd4e79204..e8fb8fac4 100644 --- a/doc/source/user/proxies/block_storage.rst +++ b/doc/source/user/proxies/block_storage.rst @@ -22,6 +22,17 @@ Volume Operations .. automethod:: openstack.block_storage.v2._proxy.Proxy.get_volume .. automethod:: openstack.block_storage.v2._proxy.Proxy.volumes +Backup Operations +^^^^^^^^^^^^^^^^^ + +.. autoclass:: openstack.block_storage.v2._proxy.Proxy + + .. automethod:: openstack.block_storage.v2._proxy.Proxy.create_backup + .. automethod:: openstack.block_storage.v2._proxy.Proxy.delete_backup + .. automethod:: openstack.block_storage.v2._proxy.Proxy.get_backup + .. automethod:: openstack.block_storage.v2._proxy.Proxy.backups + .. automethod:: openstack.block_storage.v2._proxy.Proxy.restore_backup + Type Operations ^^^^^^^^^^^^^^^ diff --git a/doc/source/user/resources/block_storage/index.rst b/doc/source/user/resources/block_storage/index.rst index e4a249416..923162185 100644 --- a/doc/source/user/resources/block_storage/index.rst +++ b/doc/source/user/resources/block_storage/index.rst @@ -4,6 +4,7 @@ Block Storage Resources .. toctree:: :maxdepth: 1 + v2/backup v2/snapshot v2/type v2/volume diff --git a/doc/source/user/resources/block_storage/v2/backup.rst b/doc/source/user/resources/block_storage/v2/backup.rst new file mode 100644 index 000000000..5c56b480e --- /dev/null +++ b/doc/source/user/resources/block_storage/v2/backup.rst @@ -0,0 +1,21 @@ +openstack.block_storage.v2.backup +================================= + +.. automodule:: openstack.block_storage.v2.backup + +The Backup Class +---------------- + +The ``Backup`` class inherits from :class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.block_storage.v2.backup.Backup + :members: + +The BackupDetail Class +---------------------- + +The ``BackupDetail`` class inherits from +:class:`~openstack.block_storage.v2.backup.Backup`. + +.. autoclass:: openstack.block_storage.v2.backup.BackupDetail + :members: diff --git a/openstack/block_storage/v2/_proxy.py b/openstack/block_storage/v2/_proxy.py index ece8b2609..a6f26417b 100644 --- a/openstack/block_storage/v2/_proxy.py +++ b/openstack/block_storage/v2/_proxy.py @@ -10,10 +10,12 @@ # License for the specific language governing permissions and limitations # under the License. +from openstack.block_storage.v2 import backup as _backup from openstack.block_storage.v2 import snapshot as _snapshot from openstack.block_storage.v2 import stats as _stats from openstack.block_storage.v2 import type as _type from openstack.block_storage.v2 import volume as _volume +from openstack import exceptions from openstack import proxy from openstack import resource @@ -209,6 +211,107 @@ class Proxy(proxy.Proxy): """ return self._list(_stats.Pools, paginated=False) + def backups(self, details=True, **query): + """Retrieve a generator of backups + + :param bool details: When set to ``False`` + :class:`~openstack.block_storage.v2.backup.Backup` objects + will be returned. The default, ``True``, will cause + :class:`~openstack.block_storage.v2.backup.BackupDetail` + objects to be returned. + :param dict query: Optional query parameters to be sent to limit the + resources being returned: + + * offset: pagination marker + * limit: pagination limit + * sort_key: Sorts by an attribute. A valid value is + name, status, container_format, disk_format, size, id, + created_at, or updated_at. Default is created_at. + The API uses the natural sorting direction of the + sort_key attribute value. + * sort_dir: Sorts by one or more sets of attribute and sort + direction combinations. If you omit the sort direction + in a set, default is desc. + + :returns: A generator of backup objects. + """ + if not self._connection.has_service('object-store'): + raise exceptions.SDKException( + 'Object-store service is required for block-store backups' + ) + backup = _backup.BackupDetail if details else _backup.Backup + return self._list(backup, paginated=True, **query) + + def get_backup(self, backup): + """Get a backup + + :param backup: The value can be the ID of a backup + or a :class:`~openstack.block_storage.v2.backup.Backup` + instance. + + :returns: Backup instance + :rtype: :class:`~openstack.block_storage.v2.backup.Backup` + """ + if not self._connection.has_service('object-store'): + raise exceptions.SDKException( + 'Object-store service is required for block-store backups' + ) + return self._get(_backup.Backup, backup) + + def create_backup(self, **attrs): + """Create a new Backup from attributes with native API + + :param dict attrs: Keyword arguments which will be used to create + a :class:`~openstack.block_storage.v2.backup.Backup` + comprised of the properties on the Backup class. + + :returns: The results of Backup creation + :rtype: :class:`~openstack.block_storage.v2.backup.Backup` + """ + if not self._connection.has_service('object-store'): + raise exceptions.SDKException( + 'Object-store service is required for block-store backups' + ) + return self._create(_backup.Backup, **attrs) + + def delete_backup(self, backup, ignore_missing=True): + """Delete a CloudBackup + + :param backup: The value can be the ID of a backup or a + :class:`~openstack.block_storage.v2.backup.Backup` instance + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised when + the zone does not exist. + When set to ``True``, no exception will be set when attempting to + delete a nonexistent zone. + + :returns: ``None`` + """ + if not self._connection.has_service('object-store'): + raise exceptions.SDKException( + 'Object-store service is required for block-store backups' + ) + self._delete(_backup.Backup, backup, + ignore_missing=ignore_missing) + + def restore_backup(self, backup, volume_id, name): + """Restore a Backup to volume + + :param backup: The value can be the ID of a backup or a + :class:`~openstack.block_storage.v2.backup.Backup` instance + :param volume_id: The ID of the volume to restore the backup to. + :param name: The name for new volume creation to restore. + + :returns: Updated backup instance + :rtype: :class:`~openstack.block_storage.v2.backup.Backup` + """ + if not self._connection.has_service('object-store'): + raise exceptions.SDKException( + 'Object-store service is required for block-store backups' + ) + backup = self._get_resource(_backup.Backup, backup) + return backup.restore(self, volume_id=volume_id, name=name) + def wait_for_status(self, res, status='ACTIVE', failures=None, interval=2, wait=120): """Wait for a resource to be in a particular status. diff --git a/openstack/block_storage/v2/backup.py b/openstack/block_storage/v2/backup.py new file mode 100644 index 000000000..8b3c1f82f --- /dev/null +++ b/openstack/block_storage/v2/backup.py @@ -0,0 +1,100 @@ +# 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 openstack import resource +from openstack import utils + + +class Backup(resource.Resource): + """Volume Backup""" + resource_key = "backup" + resources_key = "backups" + base_path = "/backups" + + _query_mapping = resource.QueryParameters( + 'all_tenants', 'limit', 'marker', + 'sort_key', 'sort_dir') + + # capabilities + allow_fetch = True + allow_create = True + allow_delete = True + allow_list = True + allow_get = True + + #: Properties + #: backup availability zone + availability_zone = resource.Body("availability_zone") + #: The container backup in + container = resource.Body("container") + #: The date and time when the resource was created. + created_at = resource.Body("created_at") + #: data timestamp + #: The time when the data on the volume was first saved. + #: If it is a backup from volume, it will be the same as created_at + #: for a backup. If it is a backup from a snapshot, + #: it will be the same as created_at for the snapshot. + data_timestamp = resource.Body('data_timestamp') + #: backup description + description = resource.Body("description") + #: Backup fail reason + fail_reason = resource.Body("fail_reason") + #: Force backup + force = resource.Body("force", type=bool) + #: has_dependent_backups + #: If this value is true, there are other backups depending on this backup. + has_dependent_backups = resource.Body('has_dependent_backups', type=bool) + #: Indicates whether the backup mode is incremental. + #: If this value is true, the backup mode is incremental. + #: If this value is false, the backup mode is full. + is_incremental = resource.Body("is_incremental", type=bool) + #: A list of links associated with this volume. *Type: list* + links = resource.Body("links", type=list) + #: backup name + name = resource.Body("name") + #: backup object count + object_count = resource.Body("object_count", type=int) + #: The size of the volume, in gibibytes (GiB). + size = resource.Body("size", type=int) + #: The UUID of the source volume snapshot. + snapshot_id = resource.Body("snapshot_id") + #: backup status + #: values: creating, available, deleting, error, restoring, error_restoring + status = resource.Body("status") + #: The date and time when the resource was updated. + updated_at = resource.Body("updated_at") + #: The UUID of the volume. + volume_id = resource.Body("volume_id") + + def restore(self, session, volume_id=None, name=None): + """Restore current backup to volume + + :param session: openstack session + :param volume_id: The ID of the volume to restore the backup to. + :param name: The name for new volume creation to restore. + :return: + """ + url = utils.urljoin(self.base_path, self.id, "restore") + body = {"restore": {"volume_id": volume_id, "name": name}} + response = session.post(url, + json=body) + self._translate_response(response) + return self + + +class BackupDetail(Backup): + """Volume Backup with Details""" + base_path = "/backups/detail" + + # capabilities + allow_list = True + + #: Properties diff --git a/openstack/tests/functional/block_storage/v2/test_backup.py b/openstack/tests/functional/block_storage/v2/test_backup.py new file mode 100644 index 000000000..08914673d --- /dev/null +++ b/openstack/tests/functional/block_storage/v2/test_backup.py @@ -0,0 +1,68 @@ +# 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 openstack.block_storage.v2 import volume as _volume +from openstack.block_storage.v2 import backup as _backup +from openstack.tests.functional import base + + +class TestBackup(base.BaseFunctionalTest): + + def setUp(self): + super(TestBackup, self).setUp() + + if not self.conn.has_service('object-store'): + self.skipTest('Object service is requred, but not available') + + self.VOLUME_NAME = self.getUniqueString() + self.VOLUME_ID = None + self.BACKUP_NAME = self.getUniqueString() + self.BACKUP_ID = None + + volume = self.conn.block_storage.create_volume( + name=self.VOLUME_NAME, + size=1) + self.conn.block_storage.wait_for_status( + volume, + status='available', + failures=['error'], + interval=5, + wait=300) + assert isinstance(volume, _volume.Volume) + self.VOLUME_ID = volume.id + + backup = self.conn.block_storage.create_backup( + name=self.BACKUP_NAME, + volume_id=volume.id) + self.conn.block_storage.wait_for_status( + backup, + status='available', + failures=['error'], + interval=5, + wait=300) + assert isinstance(backup, _backup.Backup) + self.assertEqual(self.BACKUP_NAME, backup.name) + self.BACKUP_ID = backup.id + + def tearDown(self): + sot = self.conn.block_storage.delete_backup( + self.BACKUP_ID, + ignore_missing=False) + sot = self.conn.block_storage.delete_volume( + self.VOLUME_ID, + ignore_missing=False) + self.assertIsNone(sot) + super(TestBackup, self).tearDown() + + def test_get(self): + sot = self.conn.block_storage.get_backup(self.BACKUP_ID) + self.assertEqual(self.BACKUP_NAME, sot.name) diff --git a/openstack/tests/unit/block_storage/v2/test_backup.py b/openstack/tests/unit/block_storage/v2/test_backup.py new file mode 100644 index 000000000..cc429d1b9 --- /dev/null +++ b/openstack/tests/unit/block_storage/v2/test_backup.py @@ -0,0 +1,121 @@ +# 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 copy +import mock + +from keystoneauth1 import adapter + +from openstack.tests.unit import base + +from openstack.block_storage.v2 import backup + +FAKE_ID = "6685584b-1eac-4da6-b5c3-555430cf68ff" + +BACKUP = { + "availability_zone": "az1", + "container": "volumebackups", + "created_at": "2018-04-02T10:35:27.000000", + "updated_at": "2018-04-03T10:35:27.000000", + "description": 'description', + "fail_reason": 'fail reason', + "id": FAKE_ID, + "name": "backup001", + "object_count": 22, + "size": 1, + "status": "available", + "volume_id": "e5185058-943a-4cb4-96d9-72c184c337d6", + "is_incremental": True, + "has_dependent_backups": False +} + +DETAILS = { +} + +BACKUP_DETAIL = copy.copy(BACKUP) +BACKUP_DETAIL.update(DETAILS) + + +class TestBackup(base.TestCase): + + def setUp(self): + super(TestBackup, self).setUp() + self.sess = mock.Mock(spec=adapter.Adapter) + self.sess.get = mock.Mock() + self.sess.default_microversion = mock.Mock(return_value='') + + def test_basic(self): + sot = backup.Backup(BACKUP) + self.assertEqual("backup", sot.resource_key) + self.assertEqual("backups", sot.resources_key) + self.assertEqual("/backups", sot.base_path) + self.assertTrue(sot.allow_create) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_list) + self.assertTrue(sot.allow_get) + self.assertTrue(sot.allow_fetch) + + self.assertDictEqual( + { + "all_tenants": "all_tenants", + "limit": "limit", + "marker": "marker", + "sort_dir": "sort_dir", + "sort_key": "sort_key" + }, + sot._query_mapping._mapping + ) + + def test_create(self): + sot = backup.Backup(**BACKUP) + self.assertEqual(BACKUP["id"], sot.id) + self.assertEqual(BACKUP["name"], sot.name) + self.assertEqual(BACKUP["status"], sot.status) + self.assertEqual(BACKUP["container"], sot.container) + self.assertEqual(BACKUP["availability_zone"], sot.availability_zone) + self.assertEqual(BACKUP["created_at"], sot.created_at) + self.assertEqual(BACKUP["updated_at"], sot.updated_at) + self.assertEqual(BACKUP["description"], sot.description) + self.assertEqual(BACKUP["fail_reason"], sot.fail_reason) + self.assertEqual(BACKUP["volume_id"], sot.volume_id) + self.assertEqual(BACKUP["object_count"], sot.object_count) + self.assertEqual(BACKUP["is_incremental"], sot.is_incremental) + self.assertEqual(BACKUP["size"], sot.size) + self.assertEqual(BACKUP["has_dependent_backups"], + sot.has_dependent_backups) + + +class TestBackupDetail(base.TestCase): + + def test_basic(self): + sot = backup.BackupDetail(BACKUP_DETAIL) + self.assertIsInstance(sot, backup.Backup) + self.assertEqual("/backups/detail", sot.base_path) + + def test_create(self): + sot = backup.Backup(**BACKUP_DETAIL) + self.assertEqual(BACKUP_DETAIL["id"], sot.id) + self.assertEqual(BACKUP_DETAIL["name"], sot.name) + self.assertEqual(BACKUP_DETAIL["status"], sot.status) + self.assertEqual(BACKUP_DETAIL["container"], sot.container) + self.assertEqual(BACKUP_DETAIL["availability_zone"], + sot.availability_zone) + self.assertEqual(BACKUP_DETAIL["created_at"], sot.created_at) + self.assertEqual(BACKUP_DETAIL["updated_at"], sot.updated_at) + self.assertEqual(BACKUP_DETAIL["description"], sot.description) + self.assertEqual(BACKUP_DETAIL["fail_reason"], sot.fail_reason) + self.assertEqual(BACKUP_DETAIL["volume_id"], sot.volume_id) + self.assertEqual(BACKUP_DETAIL["object_count"], sot.object_count) + self.assertEqual(BACKUP_DETAIL["is_incremental"], sot.is_incremental) + self.assertEqual(BACKUP_DETAIL["size"], sot.size) + self.assertEqual(BACKUP_DETAIL["has_dependent_backups"], + sot.has_dependent_backups) diff --git a/openstack/tests/unit/block_storage/v2/test_proxy.py b/openstack/tests/unit/block_storage/v2/test_proxy.py index 53a46773e..a9af7afea 100644 --- a/openstack/tests/unit/block_storage/v2/test_proxy.py +++ b/openstack/tests/unit/block_storage/v2/test_proxy.py @@ -9,8 +9,12 @@ # 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 + +from openstack import exceptions from openstack.block_storage.v2 import _proxy +from openstack.block_storage.v2 import backup from openstack.block_storage.v2 import snapshot from openstack.block_storage.v2 import stats from openstack.block_storage.v2 import type @@ -97,3 +101,71 @@ class TestVolumeProxy(test_proxy_base.TestProxyBase): def test_backend_pools(self): self.verify_list(self.proxy.backend_pools, stats.Pools, paginated=False) + + def test_backups_detailed(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_list(self.proxy.backups, backup.BackupDetail, + paginated=True, + method_kwargs={"details": True, "query": 1}, + expected_kwargs={"query": 1}) + + def test_backups_not_detailed(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_list(self.proxy.backups, backup.Backup, + paginated=True, + method_kwargs={"details": False, "query": 1}, + expected_kwargs={"query": 1}) + + def test_backup_get(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_get(self.proxy.get_backup, backup.Backup) + + def test_backup_delete(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_delete(self.proxy.delete_backup, backup.Backup, False) + + def test_backup_delete_ignore(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_delete(self.proxy.delete_backup, backup.Backup, True) + + def test_backup_create_attrs(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self.verify_create(self.proxy.create_backup, backup.Backup) + + def test_backup_restore(self): + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=True) + self._verify2( + 'openstack.block_storage.v2.backup.Backup.restore', + self.proxy.restore_backup, + method_args=['volume_id'], + method_kwargs={'volume_id': 'vol_id', 'name': 'name'}, + expected_args=[self.proxy], + expected_kwargs={'volume_id': 'vol_id', 'name': 'name'} + ) + + def test_backup_no_swift(self): + """Ensure proxy method raises exception if swift is not available + """ + # NOTE: mock has_service + self.proxy._connection = mock.Mock() + self.proxy._connection.has_service = mock.Mock(return_value=False) + self.assertRaises( + exceptions.SDKException, + self.proxy.restore_backup, + 'backup', + 'volume_id', + 'name') diff --git a/releasenotes/notes/block-storage-backup-5886e91fd6e423bf.yaml b/releasenotes/notes/block-storage-backup-5886e91fd6e423bf.yaml new file mode 100644 index 000000000..d6f82506b --- /dev/null +++ b/releasenotes/notes/block-storage-backup-5886e91fd6e423bf.yaml @@ -0,0 +1,3 @@ +--- +features: + - Implement block-storage.v2 Backup resource with restore functionality.