From 66d201915d716101ce7423183034a5aa981d3158 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Thu, 27 Jul 2023 15:42:13 +0100 Subject: [PATCH] block storage: Add support for services Change-Id: I6f6097bf5c8a9f81ec4100f60358c63e50b1289d Signed-off-by: Stephen Finucane --- doc/source/user/proxies/block_storage_v3.rst | 9 +- .../user/resources/block_storage/index.rst | 1 + .../resources/block_storage/v3/service.rst | 12 ++ openstack/block_storage/v3/_proxy.py | 147 +++++++++++++ openstack/block_storage/v3/service.py | 158 ++++++++++++++ .../block_storage/v3/test_service.py | 38 ++++ .../tests/unit/block_storage/v3/test_proxy.py | 48 +++++ .../unit/block_storage/v3/test_service.py | 195 ++++++++++++++++++ ...rage-service-support-ce03092ce2d7e7b9.yaml | 4 + 9 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 doc/source/user/resources/block_storage/v3/service.rst create mode 100644 openstack/block_storage/v3/service.py create mode 100644 openstack/tests/functional/block_storage/v3/test_service.py create mode 100644 openstack/tests/unit/block_storage/v3/test_service.py create mode 100644 releasenotes/notes/add-block-storage-service-support-ce03092ce2d7e7b9.yaml diff --git a/doc/source/user/proxies/block_storage_v3.rst b/doc/source/user/proxies/block_storage_v3.rst index df619305e..abe3f0e73 100644 --- a/doc/source/user/proxies/block_storage_v3.rst +++ b/doc/source/user/proxies/block_storage_v3.rst @@ -91,6 +91,14 @@ Group Type Operations update_group_type_group_specs_property, delete_group_type_group_specs_property +Service Operations +^^^^^^^^^^^^^^^^^^ + +.. autoclass:: openstack.block_storage.v3._proxy.Proxy + :noindex: + :members: find_service, services, enable_service, disable_service, + thaw_service, freeze_service, failover_service + Type Operations ^^^^^^^^^^^^^^^ @@ -140,4 +148,3 @@ BlockStorageSummary Operations .. autoclass:: openstack.block_storage.v3._proxy.Proxy :noindex: :members: summary - diff --git a/doc/source/user/resources/block_storage/index.rst b/doc/source/user/resources/block_storage/index.rst index 564597d89..92a22ae22 100644 --- a/doc/source/user/resources/block_storage/index.rst +++ b/doc/source/user/resources/block_storage/index.rst @@ -15,4 +15,5 @@ Block Storage Resources v3/snapshot v3/type v3/volume + v3/service v3/block_storage_summary diff --git a/doc/source/user/resources/block_storage/v3/service.rst b/doc/source/user/resources/block_storage/v3/service.rst new file mode 100644 index 000000000..433880a86 --- /dev/null +++ b/doc/source/user/resources/block_storage/v3/service.rst @@ -0,0 +1,12 @@ +openstack.block_storage.v3.service +================================== + +.. automodule:: openstack.block_storage.v3.service + +The Service Class +----------------- + +The ``Service`` class inherits from :class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.block_storage.v3.service.Service + :members: diff --git a/openstack/block_storage/v3/_proxy.py b/openstack/block_storage/v3/_proxy.py index b729f9720..ca41f39f5 100644 --- a/openstack/block_storage/v3/_proxy.py +++ b/openstack/block_storage/v3/_proxy.py @@ -10,6 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. +import typing as ty + from openstack.block_storage import _base_proxy from openstack.block_storage.v3 import availability_zone from openstack.block_storage.v3 import backup as _backup @@ -22,6 +24,7 @@ from openstack.block_storage.v3 import group_type as _group_type from openstack.block_storage.v3 import limits as _limits from openstack.block_storage.v3 import quota_set as _quota_set from openstack.block_storage.v3 import resource_filter as _resource_filter +from openstack.block_storage.v3 import service as _service from openstack.block_storage.v3 import snapshot as _snapshot from openstack.block_storage.v3 import stats as _stats from openstack.block_storage.v3 import type as _type @@ -1581,6 +1584,150 @@ class Proxy(_base_proxy.BaseBlockStorageProxy): query = {} return res.commit(self, **query) + # ====== SERVICES ====== + @ty.overload + def find_service( + self, + name_or_id: str, + ignore_missing: ty.Literal[True] = True, + **query, + ) -> ty.Optional[_service.Service]: + ... + + @ty.overload + def find_service( + self, + name_or_id: str, + ignore_missing: ty.Literal[False] = True, + **query, + ) -> _service.Service: + ... + + def find_service( + self, + name_or_id: str, + ignore_missing: bool = True, + **query, + ) -> ty.Optional[_service.Service]: + """Find a single service + + :param name_or_id: The name or ID of a service + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised when + the resource does not exist. + When set to ``True``, None will be returned when attempting to find + a nonexistent resource. + :param dict query: Additional attributes like 'host' + + :returns: One: class:`~openstack.block_storage.v3.service.Service` or None + :raises: :class:`~openstack.exceptions.ResourceNotFound` + when no resource can be found. + :raises: :class:`~openstack.exceptions.DuplicateResource` when multiple + resources are found. + """ + return self._find( + _service.Service, + name_or_id, + ignore_missing=ignore_missing, + **query, + ) + + def services( + self, + **query: ty.Any, + ) -> ty.Generator[_service.Service, None, None]: + """Return a generator of service + + :param kwargs query: Optional query parameters to be sent to limit + the resources being returned. + :returns: A generator of Service objects + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + return self._list(_service.Service, **query) + + def enable_service( + self, + service: ty.Union[str, _service.Service], + ) -> _service.Service: + """Enable a service + + :param service: Either the ID of a service or a + :class:`~openstack.block_storage.v3.service.Service` instance. + + :returns: Updated service instance + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + service = self._get_resource(_service.Service, service) + return service.enable(self) + + def disable_service( + self, + service: ty.Union[str, _service.Service], + *, + reason: ty.Optional[str] = None, + ) -> _service.Service: + """Disable a service + + :param service: Either the ID of a service or a + :class:`~openstack.block_storage.v3.service.Service` instance + :param str reason: The reason to disable a service + + :returns: Updated service instance + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + service = self._get_resource(_service.Service, service) + return service.disable(self, reason=reason) + + def thaw_service( + self, + service: ty.Union[str, _service.Service], + ) -> _service.Service: + """Thaw a service + + :param service: Either the ID of a service or a + :class:`~openstack.block_storage.v3.service.Service` instance + + :returns: Updated service instance + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + service = self._get_resource(_service.Service, service) + return service.thaw(self) + + def freeze_service( + self, + service: ty.Union[str, _service.Service], + ) -> _service.Service: + """Freeze a service + + :param service: Either the ID of a service or a + :class:`~openstack.block_storage.v3.service.Service` instance + + :returns: Updated service instance + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + service = self._get_resource(_service.Service, service) + return service.freeze(self) + + def failover_service( + self, + service: ty.Union[str, _service.Service], + *, + cluster: ty.Optional[str] = None, + backend_id: ty.Optional[str] = None, + ) -> _service.Service: + """Failover a service + + Only applies to replicating cinder-volume services. + + :param service: Either the ID of a service or a + :class:`~openstack.block_storage.v3.service.Service` instance + + :returns: Updated service instance + :rtype: class: `~openstack.block_storage.v3.service.Service` + """ + service = self._get_resource(_service.Service, service) + return service.failover(self, cluster=cluster, backend_id=backend_id) + # ====== RESOURCE FILTERS ====== def resource_filters(self, **query): """Retrieve a generator of resource filters diff --git a/openstack/block_storage/v3/service.py b/openstack/block_storage/v3/service.py new file mode 100644 index 000000000..fe78f1f72 --- /dev/null +++ b/openstack/block_storage/v3/service.py @@ -0,0 +1,158 @@ +# 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 exceptions +from openstack import resource +from openstack import utils + + +class Service(resource.Resource): + resources_key = 'services' + base_path = '/os-services' + + # capabilities + allow_list = True + + _query_mapping = resource.QueryParameters( + 'binary', + 'host', + ) + + # Properties + #: The ID of active storage backend (cinder-volume services only) + active_backend_id = resource.Body('active_backend_id') + #: The availability zone of service + availability_zone = resource.Body('zone') + #: The state of storage backend (cinder-volume services only) + backend_state = resource.Body('backend_state') + #: Binary name of service + binary = resource.Body('binary') + #: The cluster name (since 3.7) + cluster = resource.Body('cluster') + #: Disabled reason of service + disabled_reason = resource.Body('disabled_reason') + #: The name of the host where service runs + host = resource.Body('host') + # Whether the host is frozen or not (cinder-volume services only) + is_frozen = resource.Body('frozen') + #: Service name + name = resource.Body('name', alias='binary') + #: The volume service replication status (cinder-volume services only) + replication_status = resource.Body('replication_status') + #: State of service + state = resource.Body('state') + #: Status of service + status = resource.Body('status') + #: The date and time when the resource was updated + updated_at = resource.Body('updated_at') + + # 3.7 introduced the 'cluster' field + _max_microversion = '3.7' + + @classmethod + def find(cls, session, name_or_id, ignore_missing=True, **params): + # No direct request possible, thus go directly to list + data = cls.list(session, **params) + + result = None + for maybe_result in data: + # Since ID might be both int and str force cast + id_value = str(cls._get_id(maybe_result)) + name_value = maybe_result.name + + if str(name_or_id) in (id_value, name_value): + if 'host' in params and maybe_result['host'] != params['host']: + continue + # Only allow one resource to be found. If we already + # found a match, raise an exception to show it. + if result is None: + result = maybe_result + else: + msg = "More than one %s exists with the name '%s'." + msg = msg % (cls.__name__, name_or_id) + raise exceptions.DuplicateResource(msg) + + if result is not None: + return result + + if ignore_missing: + return None + raise exceptions.ResourceNotFound( + "No %s found for %s" % (cls.__name__, name_or_id) + ) + + def commit(self, session, prepend_key=False, **kwargs): + # we need to set prepend_key to false + return super().commit( + session, + prepend_key=prepend_key, + **kwargs, + ) + + def _action(self, session, action, body, microversion=None): + if not microversion: + microversion = session.default_microversion + url = utils.urljoin(Service.base_path, action) + response = session.put(url, json=body, microversion=microversion) + self._translate_response(response) + return self + + # TODO(stephenfin): Add support for log levels once we have the resource + # modelled (it can be done on a deployment wide basis) + + def enable(self, session): + """Enable service.""" + body = {'binary': self.binary, 'host': self.host} + return self._action(session, 'enable', body) + + def disable(self, session, *, reason=None): + """Disable service.""" + body = {'binary': self.binary, 'host': self.host} + + if not reason: + action = 'disable' + else: + action = 'disable-log-reason' + body['disabled_reason'] = reason + + return self._action(session, action, body) + + def thaw(self, session): + body = {'host': self.host} + return self._action(session, 'thaw', body) + + def freeze(self, session): + body = {'host': self.host} + return self._action(session, 'freeze', body) + + def failover( + self, + session, + *, + cluster=None, + backend_id=None, + ): + """Failover a service + + Only applies to replicating cinder-volume services. + """ + body = {'host': self.host} + if cluster: + body['cluster'] = cluster + if backend_id: + body['backend_id'] = backend_id + + action = 'failover_host' + if utils.supports_microversion(self, '3.26'): + action = 'failover' + + return self._action(session, action, body) diff --git a/openstack/tests/functional/block_storage/v3/test_service.py b/openstack/tests/functional/block_storage/v3/test_service.py new file mode 100644 index 000000000..d97b915ad --- /dev/null +++ b/openstack/tests/functional/block_storage/v3/test_service.py @@ -0,0 +1,38 @@ +# 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.tests.functional import base + + +class TestService(base.BaseFunctionalTest): + # listing services is slooowwww + TIMEOUT_SCALING_FACTOR = 2.0 + + def test_list(self): + sot = list(self.operator_cloud.block_storage.services()) + self.assertIsNotNone(sot) + + def test_disable_enable(self): + for srv in self.operator_cloud.block_storage.services(): + # only nova-block_storage can be updated + if srv.name == 'nova-block_storage': + self.operator_cloud.block_storage.disable_service(srv) + self.operator_cloud.block_storage.enable_service(srv) + break + + def test_find(self): + for srv in self.operator_cloud.block_storage.services(): + self.operator_cloud.block_storage.find_service( + srv.name, + host=srv.host, + ignore_missing=False, + ) diff --git a/openstack/tests/unit/block_storage/v3/test_proxy.py b/openstack/tests/unit/block_storage/v3/test_proxy.py index 2efe2fbfc..bb517b7e2 100644 --- a/openstack/tests/unit/block_storage/v3/test_proxy.py +++ b/openstack/tests/unit/block_storage/v3/test_proxy.py @@ -22,6 +22,7 @@ from openstack.block_storage.v3 import group_type from openstack.block_storage.v3 import limits from openstack.block_storage.v3 import quota_set from openstack.block_storage.v3 import resource_filter +from openstack.block_storage.v3 import service from openstack.block_storage.v3 import snapshot from openstack.block_storage.v3 import stats from openstack.block_storage.v3 import type @@ -316,6 +317,53 @@ class TestGroupType(TestVolumeProxy): ) +class TestService(TestVolumeProxy): + def test_services(self): + self.verify_list(self.proxy.services, service.Service) + + def test_enable_service(self): + self._verify( + 'openstack.block_storage.v3.service.Service.enable', + self.proxy.enable_service, + method_args=["value"], + expected_args=[self.proxy], + ) + + def test_disable_service(self): + self._verify( + 'openstack.block_storage.v3.service.Service.disable', + self.proxy.disable_service, + method_args=["value"], + expected_kwargs={"reason": None}, + expected_args=[self.proxy], + ) + + def test_thaw_service(self): + self._verify( + 'openstack.block_storage.v3.service.Service.thaw', + self.proxy.thaw_service, + method_args=["value"], + expected_args=[self.proxy], + ) + + def test_freeze_service(self): + self._verify( + 'openstack.block_storage.v3.service.Service.freeze', + self.proxy.freeze_service, + method_args=["value"], + expected_args=[self.proxy], + ) + + def test_failover_service(self): + self._verify( + 'openstack.block_storage.v3.service.Service.failover', + self.proxy.failover_service, + method_args=["value"], + expected_args=[self.proxy], + expected_kwargs={"backend_id": None, "cluster": None}, + ) + + class TestExtension(TestVolumeProxy): def test_extensions(self): self.verify_list(self.proxy.extensions, extension.Extension) diff --git a/openstack/tests/unit/block_storage/v3/test_service.py b/openstack/tests/unit/block_storage/v3/test_service.py new file mode 100644 index 000000000..7f66a17b0 --- /dev/null +++ b/openstack/tests/unit/block_storage/v3/test_service.py @@ -0,0 +1,195 @@ +# 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 + +from openstack.block_storage.v3 import service +from openstack.tests.unit import base + +EXAMPLE = { + "binary": "cinder-scheduler", + "disabled_reason": None, + "host": "devstack", + "state": "up", + "status": "enabled", + "updated_at": "2017-06-29T05:50:35.000000", + "zone": "nova", +} + + +class TestService(base.TestCase): + def setUp(self): + super().setUp() + self.resp = mock.Mock() + self.resp.body = {'service': {}} + self.resp.json = mock.Mock(return_value=self.resp.body) + self.resp.status_code = 200 + self.resp.headers = {} + self.sess = mock.Mock() + self.sess.put = mock.Mock(return_value=self.resp) + self.sess.default_microversion = '3.0' + + def test_basic(self): + sot = service.Service() + self.assertIsNone(sot.resource_key) + self.assertEqual('services', sot.resources_key) + self.assertEqual('/os-services', sot.base_path) + self.assertFalse(sot.allow_commit) + self.assertTrue(sot.allow_list) + self.assertFalse(sot.allow_fetch) + self.assertFalse(sot.allow_delete) + + self.assertDictEqual( + { + 'binary': 'binary', + 'host': 'host', + 'limit': 'limit', + 'marker': 'marker', + }, + sot._query_mapping._mapping, + ) + + def test_make_it(self): + sot = service.Service(**EXAMPLE) + self.assertEqual(EXAMPLE['binary'], sot.binary) + self.assertEqual(EXAMPLE['binary'], sot.name) + self.assertEqual(EXAMPLE['disabled_reason'], sot.disabled_reason) + self.assertEqual(EXAMPLE['host'], sot.host) + self.assertEqual(EXAMPLE['state'], sot.state) + self.assertEqual(EXAMPLE['status'], sot.status) + self.assertEqual(EXAMPLE['zone'], sot.availability_zone) + + def test_enable(self): + sot = service.Service(**EXAMPLE) + + res = sot.enable(self.sess) + self.assertIsNotNone(res) + + url = 'os-services/enable' + body = { + 'binary': 'cinder-scheduler', + 'host': 'devstack', + } + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + def test_disable(self): + sot = service.Service(**EXAMPLE) + + res = sot.disable(self.sess) + self.assertIsNotNone(res) + + url = 'os-services/disable' + body = { + 'binary': 'cinder-scheduler', + 'host': 'devstack', + } + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + def test_disable__with_reason(self): + sot = service.Service(**EXAMPLE) + reason = 'fencing' + + res = sot.disable(self.sess, reason=reason) + + self.assertIsNotNone(res) + + url = 'os-services/disable-log-reason' + body = { + 'binary': 'cinder-scheduler', + 'host': 'devstack', + 'disabled_reason': reason, + } + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + def test_thaw(self): + sot = service.Service(**EXAMPLE) + + res = sot.thaw(self.sess) + self.assertIsNotNone(res) + + url = 'os-services/thaw' + body = {'host': 'devstack'} + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + def test_freeze(self): + sot = service.Service(**EXAMPLE) + + res = sot.freeze(self.sess) + self.assertIsNotNone(res) + + url = 'os-services/freeze' + body = {'host': 'devstack'} + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + @mock.patch( + 'openstack.utils.supports_microversion', + autospec=True, + return_value=False, + ) + def test_failover(self, mock_supports): + sot = service.Service(**EXAMPLE) + + res = sot.failover(self.sess) + self.assertIsNotNone(res) + + url = 'os-services/failover_host' + body = {'host': 'devstack'} + self.sess.put.assert_called_with( + url, + json=body, + microversion=self.sess.default_microversion, + ) + + @mock.patch( + 'openstack.utils.supports_microversion', + autospec=True, + return_value=True, + ) + def test_failover__with_cluster(self, mock_supports): + self.sess.default_microversion = '3.26' + + sot = service.Service(**EXAMPLE) + + res = sot.failover(self.sess, cluster='foo', backend_id='bar') + self.assertIsNotNone(res) + + url = 'os-services/failover' + body = { + 'host': 'devstack', + 'cluster': 'foo', + 'backend_id': 'bar', + } + self.sess.put.assert_called_with( + url, + json=body, + microversion='3.26', + ) diff --git a/releasenotes/notes/add-block-storage-service-support-ce03092ce2d7e7b9.yaml b/releasenotes/notes/add-block-storage-service-support-ce03092ce2d7e7b9.yaml new file mode 100644 index 000000000..95fadb580 --- /dev/null +++ b/releasenotes/notes/add-block-storage-service-support-ce03092ce2d7e7b9.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Added support for block storage services.