Merge "Add volume attachment support"

This commit is contained in:
Zuul 2023-09-16 13:58:06 +00:00 committed by Gerrit Code Review
commit 0ed6eda5f5
7 changed files with 522 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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",
)

View File

@ -0,0 +1,11 @@
---
features:
- |
Added support for:
* Create Attachment
* Update Attachment
* List Attachment
* Get Attachment
* Delete Attachment
* Complete Attachment