Merge "Generic API for attaching/detaching virtual media"

This commit is contained in:
Zuul 2023-11-30 14:02:38 +00:00 committed by Gerrit Code Review
commit be242dc13b
17 changed files with 612 additions and 17 deletions

View File

@ -0,0 +1,54 @@
.. -*- rst -*-
=====================================
Attach / Detach Virtual Media (nodes)
=====================================
.. versionadded:: 1.89
Attach a generic image as virtual media device to a node or remove
it from a node using the ``v1/nodes/{node_ident}/vmedia`` endpoint.
Attach a virtual media to a node
================================
.. rest_method:: POST /v1/nodes/{node_ident}/vmedia
Attach virtual media device to a node.
Normal response code: 204
Error codes: 400,401,403,404,409
Request
-------
.. rest_parameters:: parameters.yaml
- node_ident: node_ident
- device_type: vmedia_device_type
- image_url: vmedia_image_url
- image_download_source: vmedia_image_download_source
**Example request to attach virtual media to a Node:**
.. literalinclude:: samples/node-vmedia-attach-request.json
Detach virtual media from a node
================================
.. rest_method:: DELETE /v1/nodes/{node_ident}/vmedia
Detach virtual media device from a Node.
Normal response code: 204
Error codes: 400,401,403,404
Request
-------
.. rest_parameters:: parameters.yaml
- node_ident: node_ident

View File

@ -2190,7 +2190,25 @@ versions:
in: body in: body
required: true required: true
type: array type: array
vmedia_device_type:
description: |
The type of the virtual media device used, e.g. CDROM
in: body
required: true
type: string
vmedia_image_download_source:
description: |
How the image is served to the BMC, "http" for a remote location or
"local" to use the local web server.
in: body
required: false
type: string
vmedia_image_url:
description: |
The url of the image to attach to a virtual media device.
in: body
required: true
type: string
# variables returned from volume-connector # variables returned from volume-connector
volume_connector_connector_id: volume_connector_connector_id:
description: | description: |

View File

@ -0,0 +1,4 @@
{
"device_type": "CDROM",
"image_url": "http://image"
}

View File

@ -17,6 +17,7 @@ import copy
import datetime import datetime
from http import client as http_client from http import client as http_client
import json import json
import urllib.parse
from ironic_lib import metrics_utils from ironic_lib import metrics_utils
import jsonschema import jsonschema
@ -41,6 +42,7 @@ from ironic.api.controllers.v1 import versions
from ironic.api.controllers.v1 import volume from ironic.api.controllers.v1 import volume
from ironic.api import method from ironic.api import method
from ironic.common import args from ironic.common import args
from ironic.common import boot_devices
from ironic.common import boot_modes from ironic.common import boot_modes
from ironic.common import exception from ironic.common import exception
from ironic.common.i18n import _ from ironic.common.i18n import _
@ -316,6 +318,28 @@ VIF_VALIDATOR = args.and_valid(
args.dict_valid(id=args.uuid_or_name) args.dict_valid(id=args.uuid_or_name)
) )
VMEDIA_ATTACH_VALIDATOR = args.schema({
'type': 'object',
'properties': {
'device_type': {
'type': 'string',
'enum': boot_devices.VMEDIA_DEVICES,
},
'image_url': {'type': 'string'},
'image_download_source': {
'type': 'string',
'enum': ['http', 'local', 'swift'],
},
# TODO(dtantsur): these are useful additions in the future, but the
# ISO image code does not support them.
# 'username': {'type': 'string'},
# 'password': {'type': 'string'},
# 'insecure': {'type': 'boolean'},
},
'required': ['device_type', 'image_url'],
'additionalProperties': False,
})
def get_nodes_controller_reserved_names(): def get_nodes_controller_reserved_names():
global _NODES_CONTROLLER_RESERVED_WORDS global _NODES_CONTROLLER_RESERVED_WORDS
@ -400,6 +424,18 @@ def validate_network_data(network_data):
raise exception.Invalid(msg) raise exception.Invalid(msg)
class GetNodeAndTopicMixin:
def _get_node_and_topic(self, policy_name):
rpc_node = api_utils.check_node_policy_and_retrieve(
policy_name, self.node_ident)
try:
return rpc_node, api.request.rpcapi.get_topic_for(rpc_node)
except exception.NoValidHost as e:
e.code = http_client.BAD_REQUEST
raise
class BootDeviceController(rest.RestController): class BootDeviceController(rest.RestController):
_custom_actions = { _custom_actions = {
@ -1904,20 +1940,11 @@ class NodeMaintenanceController(rest.RestController):
self._set_maintenance(rpc_node, False) self._set_maintenance(rpc_node, False)
class NodeVIFController(rest.RestController): class NodeVIFController(rest.RestController, GetNodeAndTopicMixin):
def __init__(self, node_ident): def __init__(self, node_ident):
self.node_ident = node_ident self.node_ident = node_ident
def _get_node_and_topic(self, policy_name):
rpc_node = api_utils.check_node_policy_and_retrieve(
policy_name, self.node_ident)
try:
return rpc_node, api.request.rpcapi.get_topic_for(rpc_node)
except exception.NoValidHost as e:
e.code = http_client.BAD_REQUEST
raise
@METRICS.timer('NodeVIFController.get_all') @METRICS.timer('NodeVIFController.get_all')
@method.expose() @method.expose()
def get_all(self): def get_all(self):
@ -2119,6 +2146,61 @@ class NodeChildrenController(rest.RestController):
'?parent_node={}'.format(rpc_node.uuid))} '?parent_node={}'.format(rpc_node.uuid))}
class NodeVmediaController(rest.RestController, GetNodeAndTopicMixin):
def __init__(self, node_ident):
self.node_ident = node_ident
@METRICS.timer('NodeVmediaController.post')
@method.expose(status_code=http_client.NO_CONTENT)
@method.body('vmedia')
@args.validate(vmedia=VMEDIA_ATTACH_VALIDATOR)
def post(self, vmedia):
"""Attach a virtual media to this node
:param vmedia: a dictionary of information about the attachment.
"""
parsed_url = urllib.parse.urlparse(vmedia['image_url'])
# NOTE(dtantsur): we may eventually support glance images, but for now
# let us reject everything that is not http/https.
if parsed_url.scheme not in ('http', 'https'):
raise exception.Invalid(_("Unsupported or missing URL scheme: %s")
% parsed_url.scheme)
rpc_node, topic = self._get_node_and_topic(
'baremetal:node:vmedia:attach')
api.request.rpcapi.attach_virtual_media(
api.request.context, rpc_node.uuid,
device_type=vmedia['device_type'],
image_url=vmedia['image_url'],
image_download_source=vmedia.get('image_download_source', 'local'),
topic=topic)
@METRICS.timer('NodeVmediaController.delete')
@method.expose(status_code=http_client.NO_CONTENT)
@args.validate(device_types=args.string_list)
def delete(self, device_types=None):
"""Detach a virtual media from this node
:param device_types: A collection of device types.
"""
if device_types:
invalid = [item for item in device_types
if item not in boot_devices.VMEDIA_DEVICES]
if invalid:
raise exception.Invalid(
_("Invalid device type(s) %(invalid)s "
"(valid are %(valid)s)")
% {'invalid': ', '.join(invalid),
'valid': ', '.join(boot_devices.VMEDIA_DEVICES)})
rpc_node, topic = self._get_node_and_topic(
'baremetal:node:vmedia:detach')
api.request.rpcapi.detach_virtual_media(
api.request.context, rpc_node.uuid,
device_types=device_types, topic=topic)
class NodesController(rest.RestController): class NodesController(rest.RestController):
"""REST controller for Nodes.""" """REST controller for Nodes."""
@ -2172,6 +2254,7 @@ class NodesController(rest.RestController):
'inventory': NodeInventoryController, 'inventory': NodeInventoryController,
'children': NodeChildrenController, 'children': NodeChildrenController,
'firmware': firmware.NodeFirmwareController, 'firmware': firmware.NodeFirmwareController,
'vmedia': NodeVmediaController,
} }
@pecan.expose() @pecan.expose()
@ -2199,7 +2282,9 @@ class NodesController(rest.RestController):
or (remainder[0] == 'inventory' or (remainder[0] == 'inventory'
and not api_utils.allow_node_inventory()) and not api_utils.allow_node_inventory())
or (remainder[0] == 'firmware' or (remainder[0] == 'firmware'
and not api_utils.allow_firmware_interface())): and not api_utils.allow_firmware_interface())
or (remainder[0] == 'vmedia'
and not api_utils.allow_attach_detach_vmedia())):
pecan.abort(http_client.NOT_FOUND) pecan.abort(http_client.NOT_FOUND)
if remainder[0] == 'traits' and not api_utils.allow_traits(): if remainder[0] == 'traits' and not api_utils.allow_traits():
# NOTE(mgoddard): Returning here will ensure we exhibit the # NOTE(mgoddard): Returning here will ensure we exhibit the

View File

@ -2030,3 +2030,8 @@ def allow_port_name():
Version 1.88 of the API added name field to the port object. Version 1.88 of the API added name field to the port object.
""" """
return api.request.version.minor >= versions.MINOR_88_PORT_NAME return api.request.version.minor >= versions.MINOR_88_PORT_NAME
def allow_attach_detach_vmedia():
"""Check if we should support virtual media actions."""
return api.request.version.minor >= versions.MINOR_89_ATTACH_DETACH_VMEDIA

View File

@ -126,6 +126,7 @@ BASE_VERSION = 1
# v1.86: Add firmware interface # v1.86: Add firmware interface
# v1.87: Add service verb # v1.87: Add service verb
# v1.88: Add name field to port. # v1.88: Add name field to port.
# v1.89: Add API for attaching/detaching virtual media
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -216,6 +217,7 @@ MINOR_85_UNHOLD_VERB = 85
MINOR_86_FIRMWARE_INTERFACE = 86 MINOR_86_FIRMWARE_INTERFACE = 86
MINOR_87_SERVICE = 87 MINOR_87_SERVICE = 87
MINOR_88_PORT_NAME = 88 MINOR_88_PORT_NAME = 88
MINOR_89_ATTACH_DETACH_VMEDIA = 89
# When adding another version, update: # When adding another version, update:
# - MINOR_MAX_VERSION # - MINOR_MAX_VERSION
@ -223,7 +225,7 @@ MINOR_88_PORT_NAME = 88
# explanation of what changed in the new version # explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api'] # - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_88_PORT_NAME MINOR_MAX_VERSION = MINOR_89_ATTACH_DETACH_VMEDIA
# String representations of the minor and maximum versions # String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -49,3 +49,6 @@ ISCSIBOOT = 'iscsiboot'
FLOPPY = 'floppy' FLOPPY = 'floppy'
"Boot from a floppy drive" "Boot from a floppy drive"
VMEDIA_DEVICES = [DISK, CDROM, FLOPPY]
"""Devices that make sense for virtual media attachment."""

View File

@ -1018,6 +1018,24 @@ node_policies = [
{'path': '/nodes/{node_ident}/firmware', 'method': 'GET'} {'path': '/nodes/{node_ident}/firmware', 'method': 'GET'}
], ],
), ),
policy.DocumentedRuleDefault(
name='baremetal:node:vmedia:attach',
check_str=SYSTEM_OR_PROJECT_MEMBER,
scope_types=['system', 'project'],
description='Attach a virtual media device to a node',
operations=[
{'path': '/nodes/{node_ident}/vmedia', 'method': 'POST'}\
],
),
policy.DocumentedRuleDefault(
name='baremetal:node:vmedia:detach',
check_str=SYSTEM_OR_PROJECT_MEMBER,
scope_types=['system', 'project'],
description='Detach a virtual media device from a node',
operations=[
{'path': '/nodes/{node_ident}/vmedia', 'method': 'DELETE'}
],
),
] ]
deprecated_port_reason = """ deprecated_port_reason = """

View File

@ -617,8 +617,8 @@ RELEASE_MAPPING = {
} }
}, },
'master': { 'master': {
'api': '1.88', 'api': '1.89',
'rpc': '1.58', 'rpc': '1.59',
'objects': { 'objects': {
'Allocation': ['1.1'], 'Allocation': ['1.1'],
'BIOSSetting': ['1.1'], 'BIOSSetting': ['1.1'],

View File

@ -52,6 +52,7 @@ import oslo_messaging as messaging
from oslo_utils import excutils from oslo_utils import excutils
from oslo_utils import uuidutils from oslo_utils import uuidutils
from ironic.common import boot_devices
from ironic.common import driver_factory from ironic.common import driver_factory
from ironic.common import exception from ironic.common import exception
from ironic.common import faults from ironic.common import faults
@ -75,6 +76,7 @@ from ironic.conf import CONF
from ironic.drivers import base as drivers_base from ironic.drivers import base as drivers_base
from ironic.drivers.modules import deploy_utils from ironic.drivers.modules import deploy_utils
from ironic.drivers.modules import image_cache from ironic.drivers.modules import image_cache
from ironic.drivers.modules import image_utils
from ironic.drivers.modules import inspect_utils from ironic.drivers.modules import inspect_utils
from ironic import objects from ironic import objects
from ironic.objects import base as objects_base from ironic.objects import base as objects_base
@ -94,7 +96,7 @@ class ConductorManager(base_manager.BaseConductorManager):
# NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's. # NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's.
# NOTE(pas-ha): This also must be in sync with # NOTE(pas-ha): This also must be in sync with
# ironic.common.release_mappings.RELEASE_MAPPING['master'] # ironic.common.release_mappings.RELEASE_MAPPING['master']
RPC_API_VERSION = '1.58' RPC_API_VERSION = '1.59'
target = messaging.Target(version=RPC_API_VERSION) target = messaging.Target(version=RPC_API_VERSION)
@ -3785,6 +3787,86 @@ class ConductorManager(base_manager.BaseConductorManager):
action='service', node=node.uuid, action='service', node=node.uuid,
state=node.provision_state) state=node.provision_state)
@METRICS.timer('ConductorManager.attach_virtual_media')
@messaging.expected_exceptions(exception.InvalidParameterValue,
exception.NoFreeConductorWorker,
exception.NodeLocked,
exception.UnsupportedDriverExtension)
def attach_virtual_media(self, context, node_id, device_type, image_url,
image_download_source='local'):
"""Attach a virtual media device to the node.
:param context: request context.
:param node_id: node ID or UUID.
:param image_url: URL of the image to attach, HTTP or HTTPS.
:param image_download_source: Which way to serve the image to the BMC:
"http" to serve it from the provided location, "local" to serve
it from the local web server.
:raises: UnsupportedDriverExtension if the driver does not support
this call.
:raises: InvalidParameterValue if validation of management driver
interface failed.
:raises: NodeLocked if node is locked by another conductor.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
LOG.debug("RPC attach_virtual_media called for node %(node)s "
"for device type %(type)s",
{'node': node_id, 'type': device_type})
with task_manager.acquire(context, node_id, shared=False,
purpose='attaching virtual media') as task:
task.driver.management.validate(task)
# Starting new operation, so clear the previous error.
# We'll be putting an error here soon if we fail task.
task.node.last_error = None
task.node.save()
task.set_spawn_error_hook(utils._spawn_error_handler,
task.node, "attaching virtual media")
task.spawn_after(self._spawn_worker,
do_attach_virtual_media, task,
device_type=device_type,
image_url=image_url,
image_download_source=image_download_source)
def detach_virtual_media(self, context, node_id, device_types=None):
"""Detach some or all virtual media devices from the node.
:param context: request context.
:param node_id: node ID or UUID.
:param device_types: A collection of device type, ones from
:data:`ironic.common.boot_devices.VMEDIA_DEVICES`.
If not provided, all devices are detached.
:raises: UnsupportedDriverExtension if the driver does not support
this call.
:raises: InvalidParameterValue if validation of management driver
interface failed.
:raises: NodeLocked if node is locked by another conductor.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
LOG.debug("RPC detach_virtual_media called for node %(node)s "
"for device types %(type)s",
{'node': node_id, 'type': device_types})
with task_manager.acquire(context, node_id, shared=False,
purpose='detaching virtual media') as task:
task.driver.management.validate(task)
# Starting new operation, so clear the previous error.
# We'll be putting an error here soon if we fail task.
task.node.last_error = None
task.node.save()
task.set_spawn_error_hook(utils._spawn_error_handler,
task.node, "detaching virtual media")
task.spawn_after(self._spawn_worker,
utils.run_node_action,
task, task.driver.management.detach_virtual_media,
success_msg="Device(s) %(device_types)s detached "
"from node %(node)s",
error_msg="Could not detach device(s) "
"%(device_types)s from node %(node)s: %(exc)s",
device_types=device_types)
# NOTE(TheJulia): This is the end of the class definition for the # NOTE(TheJulia): This is the end of the class definition for the
# conductor manager. Methods for RPC and stuffs should go above this # conductor manager. Methods for RPC and stuffs should go above this
@ -3971,3 +4053,35 @@ def do_sync_power_state(task, count):
task, old_power_state) task, old_power_state)
return count return count
def do_attach_virtual_media(task, device_type, image_url,
image_download_source):
assert device_type in boot_devices.VMEDIA_DEVICES
file_name = "%s.%s" % (
device_type.lower(),
'iso' if device_type == boot_devices.CDROM else 'img'
)
image_url = image_utils.prepare_remote_image(
task, image_url, file_name=file_name,
download_source=image_download_source)
utils.run_node_action(
task, task.driver.management.attach_virtual_media,
success_msg="Device %(device_type)s attached to node %(node)s",
error_msg="Could not attach device %(device_type)s "
"to node %(node)s: %(exc)s",
device_type=device_type,
image_url=image_url)
def do_detach_virtual_media(task, device_types):
utils.run_node_action(task, task.driver.management.detach_virtual_media,
success_msg="Device(s) %(device_types)s detached "
"from node %(node)s",
error_msg="Could not detach device(s) "
"%(device_types)s from node %(node)s: %(exc)s",
device_types=device_types)
for device_type in device_types:
suffix = '.iso' if device_type == boot_devices.CDROM else '.img'
image_utils.ImageHandler.unpublish_image_for_node(
task.node, prefix=device_type.lower(), suffix=suffix)

View File

@ -157,12 +157,13 @@ class ConductorAPI(object):
| 1.56 - Added continue_inspection | 1.56 - Added continue_inspection
| 1.57 - Added do_node_service | 1.57 - Added do_node_service
| 1.58 - Added support for json-rpc port usage | 1.58 - Added support for json-rpc port usage
| 1.59 - Added support for attaching/detaching virtual media
""" """
# NOTE(rloo): This must be in sync with manager.ConductorManager's. # NOTE(rloo): This must be in sync with manager.ConductorManager's.
# NOTE(pas-ha): This also must be in sync with # NOTE(pas-ha): This also must be in sync with
# ironic.common.release_mappings.RELEASE_MAPPING['master'] # ironic.common.release_mappings.RELEASE_MAPPING['master']
RPC_API_VERSION = '1.58' RPC_API_VERSION = '1.59'
def __init__(self, topic=None): def __init__(self, topic=None):
super(ConductorAPI, self).__init__() super(ConductorAPI, self).__init__()
@ -1437,3 +1438,55 @@ class ConductorAPI(object):
node_id=node_id, node_id=node_id,
service_steps=service_steps, service_steps=service_steps,
disable_ramdisk=disable_ramdisk) disable_ramdisk=disable_ramdisk)
def attach_virtual_media(self, context, node_id, device_type, image_url,
image_download_source=None, topic=None):
"""Attach a virtual media device to the node.
:param context: request context.
:param node_id: node ID or UUID.
:param image_url: URL of the image to attach, HTTP or HTTPS.
:param image_download_source: Which way to serve the image to the BMC:
"http" to serve it from the provided location, "local" to serve
it from the local web server.
:param topic: RPC topic. Defaults to self.topic.
:raises: UnsupportedDriverExtension if the driver does not support
this call.
:raises: InvalidParameterValue if validation of management driver
interface failed.
:raises: NodeLocked if node is locked by another conductor.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
cctxt = self._prepare_call(topic=topic, version='1.59')
return cctxt.call(
context, 'attach_virtual_media',
node_id=node_id,
device_type=device_type,
image_url=image_url)
def detach_virtual_media(self, context, node_id, device_types=None,
topic=None):
"""Detach some or all virtual media devices from the node.
:param context: request context.
:param node_id: node ID or UUID.
:param device_types: A collection of device type, ones from
:data:`ironic.common.boot_devices.VMEDIA_DEVICES`.
If not provided, all devices are detached.
:param topic: RPC topic. Defaults to self.topic.
:raises: UnsupportedDriverExtension if the driver does not support
this call.
:raises: InvalidParameterValue if validation of management driver
interface failed.
:raises: NodeLocked if node is locked by another conductor.
:raises: NoFreeConductorWorker when there is no free worker to start
async task.
"""
cctxt = self._prepare_call(topic=topic, version='1.59')
return cctxt.call(
context, 'detach_virtual_media',
node_id=node_id,
device_types=device_types)

View File

@ -1840,3 +1840,28 @@ def node_cache_firmware_components(task):
except exception.UnsupportedDriverExtension: except exception.UnsupportedDriverExtension:
LOG.warning('Firmware Components are not supported for node %s, ' LOG.warning('Firmware Components are not supported for node %s, '
'skipping', task.node.uuid) 'skipping', task.node.uuid)
def run_node_action(task, call, error_msg, success_msg=None, **kwargs):
"""Run a node action and report any errors via last_error.
:param task: A TaskManager instance containing the node to act on.
:param call: A callable object to invoke.
:param error_msg: A template for a failure message. Can use %(node)s,
%(exc)s and any variables from kwargs.
:param success_msg: A template for a success message. Can use %(node)s
and any variables from kwargs.
:param kwargs: Arguments to pass to the call.
"""
error = None
try:
call(task, **kwargs)
except Exception as exc:
error = error_msg % dict(kwargs, node=task.node.uuid, exc=exc)
node_history_record(task.node, event=error, error=True)
LOG.error(
error, exc_info=not isinstance(exc, exception.IronicException))
task.node.save()
if not error and success_msg:
LOG.info(success_msg, dict(kwargs, node=task.node.uuid))

View File

@ -1302,6 +1302,32 @@ class ManagementInterface(BaseInterface):
raise exception.UnsupportedDriverExtension( raise exception.UnsupportedDriverExtension(
driver=task.node.driver, extension='get_mac_addresses') driver=task.node.driver, extension='get_mac_addresses')
def attach_virtual_media(self, task, device_type, image_url):
"""Attach a virtual media device to the node.
:param task: A TaskManager instance containing the node to act on.
:param device_type: Device type, one of
:data:`ironic.common.boot_devices.VMEDIA_DEVICES`.
:param image_url: URL of the image to attach, HTTP or HTTPS.
:raises: UnsupportedDriverExtension
"""
raise exception.UnsupportedDriverExtension(
driver=task.node.driver, extension='attach_virtual_media')
def detach_virtual_media(self, task, device_types=None):
"""Detach some or all virtual media devices from the node.
:param task: A TaskManager instance containing the node to act on.
:param device_types: A collection of device type, ones from
:data:`ironic.common.boot_devices.VMEDIA_DEVICES`.
If not provided, all devices are detached.
:raises: UnsupportedDriverExtension
"""
raise exception.UnsupportedDriverExtension(
driver=task.node.driver, extension='detach_virtual_media')
class InspectInterface(BaseInterface): class InspectInterface(BaseInterface):
"""Interface for inspection-related actions.""" """Interface for inspection-related actions."""

View File

@ -96,6 +96,8 @@ class RedfishVendorPassthru(base.VendorInterface):
def eject_vmedia(self, task, **kwargs): def eject_vmedia(self, task, **kwargs):
"""Eject a virtual media device. """Eject a virtual media device.
Deprecated in favour of the generic API.
:param task: A TaskManager object. :param task: A TaskManager object.
:param kwargs: The arguments sent with vendor passthru. The optional :param kwargs: The arguments sent with vendor passthru. The optional
kwargs are:: kwargs are::

View File

@ -8625,3 +8625,128 @@ class TestNodeFirmwareComponent(test_api_base.BaseApiTest):
expect_errors=True) expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_int) self.assertEqual(http_client.NOT_FOUND, ret.status_int)
@mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for',
lambda *_: 'test-topic')
class TestNodeVmedia(test_api_base.BaseApiTest):
def setUp(self):
super().setUp()
self.version = "1.89"
self.node = obj_utils.create_test_node(self.context)
@mock.patch.object(rpcapi.ConductorAPI, 'attach_virtual_media',
autospec=True)
def test_attach(self, mock_attach):
vmedia = {'device_type': boot_devices.CDROM,
'image_url': 'https://image',
'image_download_source': 'http'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version})
self.assertEqual(http_client.NO_CONTENT, ret.status_int)
mock_attach.assert_called_once_with(
mock.ANY, mock.ANY, self.node.uuid,
device_type=boot_devices.CDROM, image_url='https://image',
image_download_source='http', topic='test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'attach_virtual_media',
autospec=True)
def test_attach_required_only(self, mock_attach):
vmedia = {'device_type': boot_devices.CDROM,
'image_url': 'http://image'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version})
self.assertEqual(http_client.NO_CONTENT, ret.status_int)
mock_attach.assert_called_once_with(
mock.ANY, mock.ANY, self.node.uuid,
device_type=boot_devices.CDROM, image_url='http://image',
image_download_source='local', topic='test-topic')
def test_attach_missing_device_type(self):
vmedia = {'image_url': 'http://image'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
self.assertIn(b"device_type", ret.body)
def test_attach_invalid_device_type(self):
vmedia = {'device_type': 'cat',
'image_url': 'http://image'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
self.assertIn(b"cat", ret.body)
def test_attach_missing_image_url(self):
vmedia = {'device_type': boot_devices.CDROM}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
self.assertIn(b"image_url", ret.body)
def test_attach_invalid_image_url(self):
vmedia = {'device_type': boot_devices.CDROM,
'image_url': 'abcd'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: self.version},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
self.assertIn(b"URL scheme", ret.body)
def test_attach_wrong_version(self):
vmedia = {'device_type': boot_devices.CDROM,
'image_url': 'http://image'}
ret = self.post_json('/nodes/%s/vmedia' % self.node.uuid, vmedia,
headers={api_base.Version.string: "1.87"},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_int)
@mock.patch.object(rpcapi.ConductorAPI, 'detach_virtual_media',
autospec=True)
def test_detach_everything(self, mock_detach):
ret = self.delete('/nodes/%s/vmedia' % self.node.uuid,
headers={api_base.Version.string: self.version})
self.assertEqual(http_client.NO_CONTENT, ret.status_int)
mock_detach.assert_called_once_with(
mock.ANY, mock.ANY, self.node.uuid,
device_types=None, topic='test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'detach_virtual_media',
autospec=True)
def test_detach_specific_via_url(self, mock_detach):
ret = self.delete('/nodes/%s/vmedia/%s'
% (self.node.uuid, boot_devices.CDROM),
headers={api_base.Version.string: self.version})
self.assertEqual(http_client.NO_CONTENT, ret.status_int)
mock_detach.assert_called_once_with(
mock.ANY, mock.ANY, self.node.uuid,
device_types=[boot_devices.CDROM], topic='test-topic')
@mock.patch.object(rpcapi.ConductorAPI, 'detach_virtual_media',
autospec=True)
def test_detach_specific_via_argument(self, mock_detach):
ret = self.delete('/nodes/%s/vmedia?device_types=%s'
% (self.node.uuid, boot_devices.CDROM),
headers={api_base.Version.string: self.version})
self.assertEqual(http_client.NO_CONTENT, ret.status_int)
mock_detach.assert_called_once_with(
mock.ANY, mock.ANY, self.node.uuid,
device_types=[boot_devices.CDROM], topic='test-topic')
def test_detach_wrong_device_types(self):
ret = self.delete('/nodes/%s/vmedia?device_types=cdrom,cat'
% self.node.uuid,
headers={api_base.Version.string: self.version},
expect_errors=True)
self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
self.assertIn(b"cat", ret.body)
def test_detach_wrong_version(self):
ret = self.delete('/nodes/%s/vmedia' % self.node.uuid,
headers={api_base.Version.string: "1.87"},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_int)

View File

@ -57,8 +57,10 @@ from ironic.conductor import verify
from ironic.db import api as dbapi from ironic.db import api as dbapi
from ironic.drivers import base as drivers_base from ironic.drivers import base as drivers_base
from ironic.drivers.modules import fake from ironic.drivers.modules import fake
from ironic.drivers.modules import image_utils
from ironic.drivers.modules import inspect_utils from ironic.drivers.modules import inspect_utils
from ironic.drivers.modules.network import flat as n_flat from ironic.drivers.modules.network import flat as n_flat
from ironic.drivers.modules import redfish
from ironic import objects from ironic import objects
from ironic.objects import base as obj_base from ironic.objects import base as obj_base
from ironic.objects import fields as obj_fields from ironic.objects import fields as obj_fields
@ -69,6 +71,8 @@ from ironic.tests.unit.objects import utils as obj_utils
CONF = cfg.CONF CONF = cfg.CONF
INFO_DICT = db_utils.get_test_redfish_info()
@mgr_utils.mock_record_keepalive @mgr_utils.mock_record_keepalive
class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin, class ChangeNodePowerStateTestCase(mgr_utils.ServiceSetUpMixin,
@ -8658,3 +8662,55 @@ class DoNodeServiceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
call_args=(servicing.do_node_service, mock.ANY, call_args=(servicing.do_node_service, mock.ANY,
{'foo': 'bar'}, False), {'foo': 'bar'}, False),
err_handler=mock.ANY, target_state='active') err_handler=mock.ANY, target_state='active')
@mock.patch.object(
task_manager.TaskManager, 'spawn_after',
lambda self, _spawn, func, *args, **kwargs: func(*args, **kwargs))
@mgr_utils.mock_record_keepalive
class VirtualMediaTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
def setUp(self):
super().setUp()
self.config(enabled_hardware_types=['redfish'],
enabled_power_interfaces=['redfish'],
enabled_boot_interfaces=['redfish-virtual-media'],
enabled_management_interfaces=['redfish'],
enabled_inspect_interfaces=['redfish'],
enabled_bios_interfaces=['redfish'])
self.node = obj_utils.create_test_node(
self.context, driver='redfish', driver_info=INFO_DICT,
provision_state=states.ACTIVE)
@mock.patch.object(image_utils, 'ISOImageCache', autospec=True)
@mock.patch.object(redfish.management.RedfishManagement, 'validate',
autospec=True)
@mock.patch.object(manager, 'do_attach_virtual_media',
autospec=True)
def test_attach_virtual_media_local(self, mock_attach, mock_validate,
mock_cache):
CONF.set_override('use_swift', 'false', group='redfish')
self.service.attach_virtual_media(self.context, self.node.id,
boot_devices.CDROM,
'https://url')
mock_validate.assert_called_once_with(mock.ANY, mock.ANY)
mock_attach.assert_called_once_with(
mock.ANY, device_type=boot_devices.CDROM,
image_url='https://url', image_download_source='local')
self.node.refresh()
self.assertIsNone(self.node.last_error)
@mock.patch.object(redfish.management.RedfishManagement, 'validate',
autospec=True)
@mock.patch.object(manager, 'do_attach_virtual_media', autospec=True)
def test_attach_virtual_media_http(self, mock_attach, mock_validate):
self.service.attach_virtual_media(self.context, self.node.id,
boot_devices.CDROM,
'https://url',
image_download_source='http')
mock_validate.assert_called_once_with(mock.ANY, mock.ANY)
mock_attach.assert_called_once_with(
mock.ANY, device_type=boot_devices.CDROM,
image_url='https://url', image_download_source='http')
self.node.refresh()
self.assertIsNone(self.node.last_error)

View File

@ -0,0 +1,5 @@
---
features:
Adds a new capability allowing to attach or detach
generic iso images as virtual media devices after
a node has been provisioned.