From 1322913dc6f2ac65f64b6ae002f1c8d8bcacf814 Mon Sep 17 00:00:00 2001 From: whoami-rajat Date: Wed, 24 May 2023 16:01:50 +0530 Subject: [PATCH] Add volume attachment support Add support for the following operations: * Attachment Create * Attachment Get * Attachment List * Attachment Delete * Attachment Update * Attachment Complete Change-Id: Id32c1607d22a88aacce815b93e23bd03eeafcded --- doc/source/user/proxies/block_storage_v3.rst | 8 + .../resources/block_storage/v3/attachment.rst | 13 ++ openstack/block_storage/v3/_proxy.py | 109 ++++++++++ openstack/block_storage/v3/attachment.py | 103 ++++++++++ .../block_storage/v3/test_attachment.py | 90 +++++++++ .../unit/block_storage/v3/test_attachment.py | 188 ++++++++++++++++++ ...e-attachment-support-b5f9a9e78ba88355.yaml | 11 + 7 files changed, 522 insertions(+) create mode 100644 doc/source/user/resources/block_storage/v3/attachment.rst create mode 100644 openstack/block_storage/v3/attachment.py create mode 100644 openstack/tests/functional/block_storage/v3/test_attachment.py create mode 100644 openstack/tests/unit/block_storage/v3/test_attachment.py create mode 100644 releasenotes/notes/add-volume-attachment-support-b5f9a9e78ba88355.yaml diff --git a/doc/source/user/proxies/block_storage_v3.rst b/doc/source/user/proxies/block_storage_v3.rst index abe3f0e73..c7cc4b085 100644 --- a/doc/source/user/proxies/block_storage_v3.rst +++ b/doc/source/user/proxies/block_storage_v3.rst @@ -148,3 +148,11 @@ BlockStorageSummary Operations .. autoclass:: openstack.block_storage.v3._proxy.Proxy :noindex: :members: summary + +Attachments +^^^^^^^^^^^ + +.. autoclass:: openstack.block_storage.v3._proxy.Proxy + :noindex: + :members: create_attachment, get_attachment, attachments, + delete_attachment, update_attachment, complete_attachment diff --git a/doc/source/user/resources/block_storage/v3/attachment.rst b/doc/source/user/resources/block_storage/v3/attachment.rst new file mode 100644 index 000000000..113ecd32e --- /dev/null +++ b/doc/source/user/resources/block_storage/v3/attachment.rst @@ -0,0 +1,13 @@ +openstack.block_storage.v3.attachment +===================================== + +.. automodule:: openstack.block_storage.v3.attachment + +The Volume Attachment Class +--------------------------- + +The ``Volume Attachment`` class inherits from +:class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.block_storage.v3.attachment.Attachment + :members: create, update, complete diff --git a/openstack/block_storage/v3/_proxy.py b/openstack/block_storage/v3/_proxy.py index 48cf3da77..c17cb5a3e 100644 --- a/openstack/block_storage/v3/_proxy.py +++ b/openstack/block_storage/v3/_proxy.py @@ -13,6 +13,7 @@ import typing as ty from openstack.block_storage import _base_proxy +from openstack.block_storage.v3 import attachment as _attachment from openstack.block_storage.v3 import availability_zone from openstack.block_storage.v3 import backup as _backup from openstack.block_storage.v3 import block_storage_summary as _summary @@ -37,6 +38,7 @@ from openstack import resource class Proxy(_base_proxy.BaseBlockStorageProxy): _resource_registry = { "availability_zone": availability_zone.AvailabilityZone, + "attachment": _attachment.Attachment, "backup": _backup.Backup, "capabilities": _capabilities.Capabilities, "extension": _extension.Extension, @@ -959,6 +961,113 @@ class Proxy(_base_proxy.BaseBlockStorageProxy): volume = self._get_resource(_volume.Volume, volume) volume.terminate_attachment(self, connector) + # ====== ATTACHMENTS ====== + + def create_attachment(self, volume, **attrs): + """Create a new attachment + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param volume: The value can be either the ID of a volume or a + :class:`~openstack.block_storage.v3.volume.Volume` instance. + :param dict attrs: Keyword arguments which will be used to create + a :class:`~openstack.block_storage.v3.attachment.Attachment` + comprised of the properties on the Attachment class like + connector, instance_id, mode etc. + :returns: The results of attachment creation + :rtype: :class:`~openstack.block_storage.v3.attachment.Attachment` + """ + volume_id = resource.Resource._get_id(volume) + return self._create( + _attachment.Attachment, volume_id=volume_id, **attrs + ) + + def get_attachment(self, attachment): + """Get a single volume + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param attachment: The value can be the ID of an attachment or a + :class:`~attachment.Attachment` instance. + + :returns: One :class:`~attachment.Attachment` + :raises: :class:`~openstack.exceptions.ResourceNotFound` + when no resource can be found. + """ + return self._get(_attachment.Attachment, attachment) + + def attachments(self, **query): + """Returns a generator of attachments. + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param kwargs query: Optional query parameters to be sent to limit + the resources being returned. + + :returns: A generator of attachment objects. + """ + return self._list(_attachment.Attachment, **query) + + def delete_attachment(self, attachment, ignore_missing=True): + """Delete an attachment + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param type: The value can be either the ID of a attachment or a + :class:`~openstack.block_storage.v3.attachment.Attachment` + instance. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be + raised when the attachment does not exist. + When set to ``True``, no exception will be set when + attempting to delete a nonexistent attachment. + + :returns: ``None`` + """ + self._delete( + _attachment.Attachment, + attachment, + ignore_missing=ignore_missing, + ) + + def update_attachment(self, attachment, **attrs): + """Update an attachment + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param attachment: The value can be the ID of an attachment or a + :class:`~openstack.block_storage.v3.attachment.Attachment` + instance. + :param dict attrs: Keyword arguments which will be used to update + a :class:`~openstack.block_storage.v3.attachment.Attachment` + comprised of the properties on the Attachment class + + :returns: The updated attachment + :rtype: :class:`~openstack.volume.v3.attachment.Attachment` + """ + return self._update(_attachment.Attachment, attachment, **attrs) + + def complete_attachment(self, attachment): + """Complete an attachment + + This is an internal API and should only be called by services + consuming volume attachments like nova, glance, ironic etc. + + :param attachment: The value can be the ID of an attachment or a + :class:`~openstack.block_storage.v3.attachment.Attachment` + instance. + + :returns: ``None`` + :rtype: :class:`~openstack.volume.v3.attachment.Attachment` + """ + attachment_obj = self._get_resource(_attachment.Attachment, attachment) + return attachment_obj.complete(self) + # ====== BACKEND POOLS ====== def backend_pools(self, **query): """Returns a generator of cinder Back-end storage pools diff --git a/openstack/block_storage/v3/attachment.py b/openstack/block_storage/v3/attachment.py new file mode 100644 index 000000000..ba6e845a0 --- /dev/null +++ b/openstack/block_storage/v3/attachment.py @@ -0,0 +1,103 @@ +# 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 os + +from openstack import exceptions +from openstack import resource +from openstack import utils + + +class Attachment(resource.Resource): + resource_key = "attachment" + resources_key = "attachments" + base_path = "/attachments" + + # capabilities + allow_create = True + allow_delete = True + allow_commit = True + allow_list = True + allow_get = True + allow_fetch = True + + _max_microversion = "3.54" + + # Properties + #: The ID of the attachment. + id = resource.Body("id") + #: The status of the attachment. + status = resource.Body("status") + #: The UUID of the attaching instance. + instance = resource.Body("instance") + #: The UUID of the volume which the attachment belongs to. + volume_id = resource.Body("volume_id") + #: The time when attachment is attached. + attached_at = resource.Body("attach_time") + #: The time when attachment is detached. + detached_at = resource.Body("detach_time") + #: The attach mode of attachment, read-only ('ro') or read-and-write + # ('rw'), default is 'rw'. + attach_mode = resource.Body("mode") + #: The connection info used for server to connect the volume. + connection_info = resource.Body("connection_info") + #: The connector object. + connector = resource.Body("connector") + + def create( + self, + session, + prepend_key=True, + base_path=None, + *, + resource_request_key=None, + resource_response_key=None, + microversion=None, + **params, + ): + if utils.supports_microversion(session, '3.54'): + if not self.attach_mode: + self._body.clean(only={'mode'}) + return super().create( + session, + prepend_key=prepend_key, + base_path=base_path, + resource_request_key=resource_request_key, + resource_response_key=resource_response_key, + microversion=microversion, + **params, + ) + + def complete(self, session, *, microversion=None): + """Mark the attachment as completed.""" + body = {'os-complete': self.id} + if not microversion: + microversion = self._get_microversion(session, action='commit') + url = os.path.join(Attachment.base_path, self.id, 'action') + response = session.post(url, json=body, microversion=microversion) + exceptions.raise_from_response(response) + + def _prepare_request_body( + self, + patch, + prepend_key, + *, + resource_request_key=None, + ): + body = self._body.dirty + if body.get('volume_id'): + body['volume_uuid'] = body.pop('volume_id') + if body.get('instance'): + body['instance_uuid'] = body.pop('instance') + if prepend_key and self.resource_key is not None: + body = {self.resource_key: body} + return body diff --git a/openstack/tests/functional/block_storage/v3/test_attachment.py b/openstack/tests/functional/block_storage/v3/test_attachment.py new file mode 100644 index 000000000..b7b85752e --- /dev/null +++ b/openstack/tests/functional/block_storage/v3/test_attachment.py @@ -0,0 +1,90 @@ +# 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.v3 import volume as _volume +from openstack.tests.functional.block_storage.v3 import base + + +class TestAttachment(base.BaseBlockStorageTest): + """Test class for volume attachment operations. + + We have implemented a test that performs attachment create + and attachment delete operations. Attachment create requires + the instance ID and the volume ID for which we have created a + volume resource and an instance resource. + We haven't implemented attachment update test since it requires + the host connector information which is not readily available to + us and hard to retrieve. Without passing this information, the + attachment update operation will fail. + Similarly, we haven't implement attachment complete test since it + depends on attachment update and can only be performed when the volume + status is 'attaching' which is done by attachment update operation. + """ + + def setUp(self): + super().setUp() + + # Create Volume + self.volume_name = self.getUniqueString() + + volume = self.user_cloud.block_storage.create_volume( + name=self.volume_name, size=1 + ) + self.user_cloud.block_storage.wait_for_status( + volume, + status='available', + failures=['error'], + interval=2, + wait=self._wait_for_timeout, + ) + self.assertIsInstance(volume, _volume.Volume) + self.VOLUME_ID = volume.id + + # Create Server + self.server_name = self.getUniqueString() + self.server = self.operator_cloud.compute.create_server( + name=self.server_name, + flavor_id=self.flavor.id, + image_id=self.image.id, + networks='none', + ) + self.operator_cloud.compute.wait_for_server( + self.server, wait=self._wait_for_timeout + ) + + def tearDown(self): + # Since delete_on_termination flag is set to True, we + # don't need to cleanup the volume manually + result = self.conn.compute.delete_server(self.server.id) + self.conn.compute.wait_for_delete( + self.server, wait=self._wait_for_timeout + ) + self.assertIsNone(result) + super().tearDown() + + def test_attachment(self): + attachment = self.conn.block_storage.create_attachment( + self.VOLUME_ID, + connector={}, + instance_id=self.server.id, + ) + self.assertIn('id', attachment) + self.assertIn('status', attachment) + self.assertIn('instance', attachment) + self.assertIn('volume_id', attachment) + self.assertIn('attached_at', attachment) + self.assertIn('detached_at', attachment) + self.assertIn('attach_mode', attachment) + self.assertIn('connection_info', attachment) + attachment = self.user_cloud.block_storage.delete_attachment( + attachment.id, ignore_missing=False + ) diff --git a/openstack/tests/unit/block_storage/v3/test_attachment.py b/openstack/tests/unit/block_storage/v3/test_attachment.py new file mode 100644 index 000000000..af05a64ea --- /dev/null +++ b/openstack/tests/unit/block_storage/v3/test_attachment.py @@ -0,0 +1,188 @@ +# 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 keystoneauth1 import adapter + +from openstack.block_storage.v3 import attachment +from openstack import resource +from openstack.tests.unit import base + + +FAKE_ID = "92dc3671-d0ab-4370-8058-c88a71661ec5" +FAKE_VOL_ID = "138e4a2e-85ef-4f96-a0d0-9f3ef9f32987" +FAKE_INSTANCE_UUID = "ee9ae89e-d4fc-4c95-93ad-d9e80f240cae" + +CONNECTION_INFO = { + "access_mode": "rw", + "attachment_id": "92dc3671-d0ab-4370-8058-c88a71661ec5", + "auth_enabled": True, + "auth_username": "cinder", + "cacheable": False, + "cluster_name": "ceph", + "discard": True, + "driver_volume_type": "rbd", + "encrypted": False, + "hosts": ["127.0.0.1"], + "name": "volumes/volume-138e4a2e-85ef-4f96-a0d0-9f3ef9f32987", + "ports": ["6789"], + "secret_type": "ceph", + "secret_uuid": "e5d27872-64ab-4d8c-8c25-4dbdc522fbbf", + "volume_id": "138e4a2e-85ef-4f96-a0d0-9f3ef9f32987", +} + +CONNECTOR = { + "do_local_attach": False, + "host": "devstack-VirtualBox", + "initiator": "iqn.2005-03.org.open-iscsi:1f6474a01f9a", + "ip": "127.0.0.1", + "multipath": False, + "nqn": "nqn.2014-08.org.nvmexpress:uuid:4dfe457e-6206-4a61-b547-5a9d0e2fa557", + "nvme_native_multipath": False, + "os_type": "linux", + "platform": "x86_64", + "system_uuid": "2f4d1bf2-8a9e-864f-80ec-d265222bf145", + "uuid": "87c73a20-e7f9-4370-ad85-5829b54675d7", +} + +ATTACHMENT = { + "id": FAKE_ID, + "status": "attached", + "instance": FAKE_INSTANCE_UUID, + "volume_id": FAKE_VOL_ID, + "attached_at": "2023-07-07T10:30:40.000000", + "detached_at": None, + "attach_mode": "rw", + "connection_info": CONNECTION_INFO, +} + + +class TestAttachment(base.TestCase): + def setUp(self): + super(TestAttachment, self).setUp() + self.resp = mock.Mock() + self.resp.body = None + self.resp.json = mock.Mock(return_value=self.resp.body) + self.resp.headers = {} + self.resp.status_code = 202 + + self.sess = mock.Mock(spec=adapter.Adapter) + self.sess.get = mock.Mock() + self.sess.post = mock.Mock(return_value=self.resp) + self.sess.put = mock.Mock(return_value=self.resp) + self.sess.default_microversion = "3.54" + + def test_basic(self): + sot = attachment.Attachment(ATTACHMENT) + self.assertEqual("attachment", sot.resource_key) + self.assertEqual("attachments", sot.resources_key) + self.assertEqual("/attachments", 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_commit) + self.assertIsNotNone(sot._max_microversion) + + self.assertDictEqual( + { + "limit": "limit", + "marker": "marker", + }, + sot._query_mapping._mapping, + ) + + def test_create_resource(self): + sot = attachment.Attachment(**ATTACHMENT) + self.assertEqual(ATTACHMENT["id"], sot.id) + self.assertEqual(ATTACHMENT["status"], sot.status) + self.assertEqual(ATTACHMENT["instance"], sot.instance) + self.assertEqual(ATTACHMENT["volume_id"], sot.volume_id) + self.assertEqual(ATTACHMENT["attached_at"], sot.attached_at) + self.assertEqual(ATTACHMENT["detached_at"], sot.detached_at) + self.assertEqual(ATTACHMENT["attach_mode"], sot.attach_mode) + self.assertEqual(ATTACHMENT["connection_info"], sot.connection_info) + + @mock.patch( + 'openstack.utils.supports_microversion', + autospec=True, + return_value=True, + ) + @mock.patch.object(resource.Resource, '_translate_response') + def test_create_no_mode_no_instance_id(self, mock_translate, mock_mv): + self.sess.default_microversion = "3.27" + mock_mv.return_value = False + sot = attachment.Attachment() + FAKE_MODE = "rw" + sot.create( + self.sess, + volume_id=FAKE_VOL_ID, + connector=CONNECTOR, + instance=None, + mode=FAKE_MODE, + ) + self.sess.post.assert_called_with( + '/attachments', + json={'attachment': {}}, + headers={}, + microversion="3.27", + params={ + 'volume_id': FAKE_VOL_ID, + 'connector': CONNECTOR, + 'instance': None, + 'mode': 'rw', + }, + ) + self.sess.default_microversion = "3.54" + + @mock.patch( + 'openstack.utils.supports_microversion', + autospec=True, + return_value=True, + ) + @mock.patch.object(resource.Resource, '_translate_response') + def test_create_with_mode_with_instance_id(self, mock_translate, mock_mv): + sot = attachment.Attachment() + FAKE_MODE = "rw" + sot.create( + self.sess, + volume_id=FAKE_VOL_ID, + connector=CONNECTOR, + instance=FAKE_INSTANCE_UUID, + mode=FAKE_MODE, + ) + self.sess.post.assert_called_with( + '/attachments', + json={'attachment': {}}, + headers={}, + microversion="3.54", + params={ + 'volume_id': FAKE_VOL_ID, + 'connector': CONNECTOR, + 'instance': FAKE_INSTANCE_UUID, + 'mode': FAKE_MODE, + }, + ) + + @mock.patch.object(resource.Resource, '_translate_response') + def test_complete(self, mock_translate): + sot = attachment.Attachment() + sot.id = FAKE_ID + sot.complete(self.sess) + self.sess.post.assert_called_with( + '/attachments/%s/action' % FAKE_ID, + json={ + 'os-complete': '92dc3671-d0ab-4370-8058-c88a71661ec5', + }, + microversion="3.54", + ) diff --git a/releasenotes/notes/add-volume-attachment-support-b5f9a9e78ba88355.yaml b/releasenotes/notes/add-volume-attachment-support-b5f9a9e78ba88355.yaml new file mode 100644 index 000000000..d540fd058 --- /dev/null +++ b/releasenotes/notes/add-volume-attachment-support-b5f9a9e78ba88355.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Added support for: + + * Create Attachment + * Update Attachment + * List Attachment + * Get Attachment + * Delete Attachment + * Complete Attachment