From 873e5a899e4a92bfc53a9e8a2c1b77d0cc551651 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Fri, 4 Apr 2025 14:10:15 +0100 Subject: [PATCH] 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 --- doc/source/user/proxies/baremetal.rst | 6 ++ openstack/baremetal/v1/_common.py | 3 + openstack/baremetal/v1/_proxy.py | 49 ++++++++++ openstack/baremetal/v1/node.py | 96 ++++++++++++++++++- .../baremetal/test_baremetal_node.py | 32 +++++++ .../tests/unit/baremetal/v1/test_node.py | 86 +++++++++++++++++ .../add-vmedia-support-20494ed415e5b32b.yaml | 4 + 7 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/add-vmedia-support-20494ed415e5b32b.yaml diff --git a/doc/source/user/proxies/baremetal.rst b/doc/source/user/proxies/baremetal.rst index 557c8d4eb..f30beae15 100644 --- a/doc/source/user/proxies/baremetal.rst +++ b/doc/source/user/proxies/baremetal.rst @@ -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 diff --git a/openstack/baremetal/v1/_common.py b/openstack/baremetal/v1/_common.py index cac214c6b..da8a6c4ed 100644 --- a/openstack/baremetal/v1/_common.py +++ b/openstack/baremetal/v1/_common.py @@ -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""" diff --git a/openstack/baremetal/v1/_proxy.py b/openstack/baremetal/v1/_proxy.py index 5a86066e6..3661354b4 100644 --- a/openstack/baremetal/v1/_proxy.py +++ b/openstack/baremetal/v1/_proxy.py @@ -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( diff --git a/openstack/baremetal/v1/node.py b/openstack/baremetal/v1/node.py index 8debcf8b5..c6b2d12ec 100644 --- a/openstack/baremetal/v1/node.py +++ b/openstack/baremetal/v1/node.py @@ -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, diff --git a/openstack/tests/functional/baremetal/test_baremetal_node.py b/openstack/tests/functional/baremetal/test_baremetal_node.py index 92bbd68ff..54fc7c9c9 100644 --- a/openstack/tests/functional/baremetal/test_baremetal_node.py +++ b/openstack/tests/functional/baremetal/test_baremetal_node.py @@ -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' diff --git a/openstack/tests/unit/baremetal/v1/test_node.py b/openstack/tests/unit/baremetal/v1/test_node.py index 008559fe8..f1300c275 100644 --- a/openstack/tests/unit/baremetal/v1/test_node.py +++ b/openstack/tests/unit/baremetal/v1/test_node.py @@ -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): diff --git a/releasenotes/notes/add-vmedia-support-20494ed415e5b32b.yaml b/releasenotes/notes/add-vmedia-support-20494ed415e5b32b.yaml new file mode 100644 index 000000000..346953282 --- /dev/null +++ b/releasenotes/notes/add-vmedia-support-20494ed415e5b32b.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Implements virtual media attach/detach API for bare metal nodes.