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
This commit is contained in:
parent
d474eb84c6
commit
1322913dc6
@ -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
|
||||
|
13
doc/source/user/resources/block_storage/v3/attachment.rst
Normal file
13
doc/source/user/resources/block_storage/v3/attachment.rst
Normal 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
|
@ -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
|
||||
|
103
openstack/block_storage/v3/attachment.py
Normal file
103
openstack/block_storage/v3/attachment.py
Normal 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
|
@ -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
|
||||
)
|
188
openstack/tests/unit/block_storage/v3/test_attachment.py
Normal file
188
openstack/tests/unit/block_storage/v3/test_attachment.py
Normal 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",
|
||||
)
|
@ -0,0 +1,11 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Added support for:
|
||||
|
||||
* Create Attachment
|
||||
* Update Attachment
|
||||
* List Attachment
|
||||
* Get Attachment
|
||||
* Delete Attachment
|
||||
* Complete Attachment
|
Loading…
Reference in New Issue
Block a user