Add virtual media attachment and detachment support

This change introduces methods for attaching and detaching virtual media
devices to/from nodes, enhancing the capabilities of the baremetal API.

Changes include:

- Added `VMEDIA_VERSION` constant in `_common.py` for API versioning.
- Introduced `attach_vmedia` and `detach_vmedia` methods in the `Node`
  class.
- Added `attach_vmedia_to_node` and `detach_vmedia_from_node` methods in
  the `Proxy` class.
- Added unit and functional test for the features.

Change-Id: Id41d45ad78b07f8ce9cca92444e1603f3882fe53
This commit is contained in:
Stephen Finucane
2025-04-04 14:10:15 +01:00
parent 79fbbbabff
commit 873e5a899e
7 changed files with 275 additions and 1 deletions

View File

@@ -58,6 +58,12 @@ Chassis Operations
:members: chassis, find_chassis, get_chassis,
create_chassis, update_chassis, patch_chassis, delete_chassis
Virtual Media Operations
^^^^^^^^^^^^^^^^^^^^^^^^
.. autoclass:: openstack.baremetal.v1._proxy.Proxy
:noindex:
:members: attach_vmedia_to_node, detach_vmedia_from_node
VIF Operations
^^^^^^^^^^^^^^
.. autoclass:: openstack.baremetal.v1._proxy.Proxy

View File

@@ -95,6 +95,9 @@ CHANGE_BOOT_MODE_VERSION = '1.76'
FIRMWARE_VERSION = '1.86'
"""API version in which firmware components of a node can be accessed"""
VMEDIA_VERSION = '1.89'
"""API version in which the virtual media operations were introduced."""
RUNBOOKS_VERSION = '1.92'
"""API version in which a runbook can be used in place of arbitrary steps
for provisioning"""

View File

@@ -1087,6 +1087,55 @@ class Proxy(proxy.Proxy):
_portgroup.PortGroup, port_group, ignore_missing=ignore_missing
)
# ========== Virtual Media ==========
def attach_vmedia_to_node(
self,
node,
device_type,
image_url,
image_download_source=None,
retry_on_conflict=True,
):
"""Attach virtual media device to a node.
:param node: The value can be either the name or ID of a node or
a :class:`~openstack.baremetal.v1.node.Node` instance.
:param device_type: The type of virtual media device.
:param image_url: The URL of the image to attach.
:param image_download_source: The source of the image download.
:param retry_on_conflict: Whether to retry HTTP CONFLICT errors.
This can happen when either the virtual media is already used on
a node or the node is locked. Since the latter happens more often,
the default value is True.
:return: ``None``
:raises: :exc:`~openstack.exceptions.NotSupported` if the server
does not support the VMEDIA API.
"""
res = self._get_resource(_node.Node, node)
res.attach_vmedia(
self,
device_type=device_type,
image_url=image_url,
image_download_source=image_download_source,
retry_on_conflict=retry_on_conflict,
)
def detach_vmedia_from_node(self, node, device_types=None):
"""Detach virtual media from the node.
:param node: The value can be either the name or ID of a node or
a :class:`~openstack.baremetal.v1.node.Node` instance.
:param device_types: A list with the types of virtual media
devices to detach.
:return: ``True`` if the virtual media was detached,
otherwise ``False``.
:raises: :exc:`~openstack.exceptions.NotSupported` if the server
does not support the VMEDIA API.
"""
res = self._get_resource(_node.Node, node)
return res.detach_vmedia(self, device_types=device_types)
# ========== VIFs ==========
def attach_vif_to_node(

View File

@@ -212,7 +212,7 @@ class Node(_common.Resource):
#: unit of a specific type of resource. Added in API microversion 1.21.
resource_class = resource.Body("resource_class")
#: A string representing the current service step being executed upon.
#: Added in API microversion 1.87.
#: Added in API microversion 1.89.
service_step = resource.Body("service_step")
#: A string representing the uuid or logical name of a runbook as an
#: alternative to providing ``clean_steps`` or ``service_steps``.
@@ -812,6 +812,100 @@ class Node(_common.Resource):
if wait:
self.wait_for_power_state(session, expected, timeout=timeout)
def attach_vmedia(
self,
session,
device_type,
image_url,
image_download_source=None,
retry_on_conflict=True,
):
"""Attach virtual media device to a node.
:param session: The session to use for making this request.
:type session: :class:`~keystoneauth1.adapter.Adapter`
:param device_type: The type of virtual media device.
:param image_url: The URL of the image to attach.
:param image_download_source: The source of the image download.
:param retry_on_conflict: Whether to retry HTTP CONFLICT errors.
This can happen when either the virtual media is already used on
a node or the node is locked. Since the latter happens more often,
the default value is True.
:return: ``None``
:raises: :exc:`~openstack.exceptions.NotSupported` if the server
does not support the VMEDIA API.
"""
session = self._get_session(session)
version = self._assert_microversion_for(
session,
_common.VMEDIA_VERSION,
error_message=("Cannot use virtual media API"),
)
# Prepare the request and create the request body
request = self._prepare_request(requires_id=True)
request.url = utils.urljoin(request.url, 'vmedia')
body = {"device_type": device_type, "image_url": image_url}
if image_download_source:
body["image_download_source"] = image_download_source
retriable_status_codes = _common.RETRIABLE_STATUS_CODES
if not retry_on_conflict:
retriable_status_codes = list(set(retriable_status_codes) - {409})
response = session.post(
request.url,
json=body,
headers=request.headers,
microversion=version,
retriable_status_codes=retriable_status_codes,
)
msg = f"Failed to attach Virtual Media to bare metal node {self.id}"
exceptions.raise_from_response(response, error_message=msg)
def detach_vmedia(self, session, device_types=None):
"""Detach virtual media from a node
:param session: The session to use for making this request.
:type session: :class:`~keystoneauth1.adapter.Adapter`
:param device_types: A list with the types of virtual media
devices to detach.
:return: ``True`` if the virtual media was detached,
otherwise ``False``.
:raises: :exc:`~openstack.exceptions.NotSupported` if the server
does not support the VMEDIA API
"""
session = self._get_session(session)
version = self._assert_microversion_for(
session,
_common.VMEDIA_VERSION,
error_message=("Cannot use virtual media API"),
)
request = self._prepare_request(requires_id=True)
request.url = utils.urljoin(request.url, 'vmedia')
delete_kwargs = {
'headers': request.headers,
'microversion': version,
'retriable_status_codes': _common.RETRIABLE_STATUS_CODES,
}
if device_types:
delete_kwargs['json'] = {
'device_types': _common.comma_separated_list(device_types)
}
response = session.delete(request.url, **delete_kwargs)
if response.status_code == 400:
session.log.debug(
"Virtual media doesn't exist for node %(node)s",
{'node': self.id},
)
msg = f"Failed to detach virtual media from bare metal node {self.id}"
exceptions.raise_from_response(response, error_message=msg)
def attach_vif(
self,
session: adapter.Adapter,

View File

@@ -467,6 +467,38 @@ class TestBareMetalVif(base.BaseBaremetalTest):
)
class TestBareMetalVirtualMedia(base.BaseBaremetalTest):
min_microversion = '1.89'
def setUp(self):
super().setUp()
self.node = self.create_node(network_interface='noop')
self.device_type = "CDROM"
self.image_url = "http://image"
def test_node_vmedia_attach_detach(self):
self.conn.baremetal.attach_vmedia_to_node(
self.node, self.device_type, self.image_url
)
res = self.conn.baremetal.detach_vmedia_from_node(self.node)
self.assertNone(res)
def test_node_vmedia_negative(self):
uuid = "5c9dcd04-2073-49bc-9618-99ae634d8971"
self.assertRaises(
exceptions.ResourceNotFound,
self.conn.baremetal.attach_vmedia_to_node,
uuid,
self.device_type,
self.image_url,
)
self.assertRaises(
exceptions.ResourceNotFound,
self.conn.baremetal.detach_vmedia_from_node,
uuid,
)
class TestTraits(base.BaseBaremetalTest):
min_microversion = '1.37'

View File

@@ -736,6 +736,92 @@ class TestNodeVif(base.TestCase):
)
@mock.patch.object(exceptions, 'raise_from_response', mock.Mock())
@mock.patch.object(node.Node, '_get_session', lambda self, x: x)
class TestNodeVmedia(base.TestCase):
def setUp(self):
super().setUp()
self.session = mock.Mock(spec=adapter.Adapter)
self.session.default_microversion = '1.89'
self.session.log = mock.Mock()
self.node = node.Node(
id='c29db401-b6a7-4530-af8e-20a720dee946', driver=FAKE['driver']
)
self.device_type = "CDROM"
self.image_url = "http://image"
def test_attach_vmedia(self):
self.assertIsNone(
self.node.attach_vmedia(
self.session, self.device_type, self.image_url
)
)
self.session.post.assert_called_once_with(
f'nodes/{self.node.id}/vmedia',
json={
'device_type': self.device_type,
'image_url': self.image_url,
},
headers=mock.ANY,
microversion='1.89',
retriable_status_codes=[409, 503],
)
def test_attach_vmedia_no_retries(self):
self.assertIsNone(
self.node.attach_vmedia(
self.session,
self.device_type,
self.image_url,
retry_on_conflict=False,
)
)
self.session.post.assert_called_once_with(
f'nodes/{self.node.id}/vmedia',
json={
'device_type': self.device_type,
'image_url': self.image_url,
},
headers=mock.ANY,
microversion='1.89',
retriable_status_codes=[503],
)
def test_detach_vmedia_existing(self):
self.assertIsNone(self.node.detach_vmedia(self.session))
self.session.delete.assert_called_once_with(
f'nodes/{self.node.id}/vmedia',
headers=mock.ANY,
microversion='1.89',
retriable_status_codes=_common.RETRIABLE_STATUS_CODES,
)
def test_detach_vmedia_missing(self):
self.session.delete.return_value.status_code = 400
self.assertIsNone(self.node.detach_vmedia(self.session))
self.session.delete.assert_called_once_with(
f'nodes/{self.node.id}/vmedia',
headers=mock.ANY,
microversion='1.89',
retriable_status_codes=_common.RETRIABLE_STATUS_CODES,
)
def test_incompatible_microversion(self):
self.session.default_microversion = '1.1'
self.assertRaises(
exceptions.NotSupported,
self.node.attach_vmedia,
self.session,
self.device_type,
self.image_url,
)
self.assertRaises(
exceptions.NotSupported,
self.node.detach_vmedia,
self.session,
)
@mock.patch.object(exceptions, 'raise_from_response', mock.Mock())
@mock.patch.object(node.Node, '_get_session', lambda self, x: x)
class TestNodeValidate(base.TestCase):

View File

@@ -0,0 +1,4 @@
---
features:
- |
Implements virtual media attach/detach API for bare metal nodes.