Merge "Add new ironic driver"

This commit is contained in:
Zuul 2023-12-07 11:33:41 +00:00 committed by Gerrit Code Review
commit e901c91f62
7 changed files with 621 additions and 2 deletions

View File

@ -19,6 +19,9 @@ SUSHY_EMULATOR_AUTH_FILE = None
# The OpenStack cloud ID to use. This option enables OpenStack driver. # The OpenStack cloud ID to use. This option enables OpenStack driver.
SUSHY_EMULATOR_OS_CLOUD = None SUSHY_EMULATOR_OS_CLOUD = None
# The OpenStack cloud ID to use for Ironic. This option enables Ironic driver.
SUSHY_EMULATOR_IRONIC_CLOUD = None
# The libvirt URI to use. This option enables libvirt driver. # The libvirt URI to use. This option enables libvirt driver.
SUSHY_EMULATOR_LIBVIRT_URI = u'qemu:///system' SUSHY_EMULATOR_LIBVIRT_URI = u'qemu:///system'

View File

@ -303,6 +303,69 @@ And flip its power state via the Redfish call:
You can have as many OpenStack instances as you need. The instances can be You can have as many OpenStack instances as you need. The instances can be
concurrently managed over Redfish and functionally similar tools. concurrently managed over Redfish and functionally similar tools.
Systems resource driver: Ironic
++++++++++++++++++++++++++++++++++
You can use the Ironic driver to manage Ironic baremetal instance to simulated
Redfish API. You may want to do this if you require a redfish compatible endpoint
but don't have direct access to the BMC (you only have access via Ironic) or
the BMC doesn't support redfish.
Assuming your baremetal cloud is setup you can invoke the Redfish emulator by
running
.. code-block:: bash
sushy-emulator --ironic-cloud baremetal-cloud
* Running on http://localhost:8000/ (Press CTRL+C to quit)
By this point you should be able to see your Baremetal instances among the
Redfish *Systems*:
.. code-block:: bash
curl http://localhost:8000/redfish/v1/Systems/
{
"@odata.type": "#ComputerSystemCollection.ComputerSystemCollection",
"Name": "Computer System Collection",
"Members@odata.count": 1,
"Members": [
{
"@odata.id": "/redfish/v1/Systems/<uuid>"
}
],
"@odata.context": "/redfish/v1/$metadata#ComputerSystemCollection.ComputerSystemCollection",
"@odata.id": "/redfish/v1/Systems",
"@Redfish.Copyright": "Copyright 2014-2016 Distributed Management Task Force, Inc. (DMTF). For the full DMTF copyright policy, see http://www.dmtf.org/about/policies/copyright."
}
And flip its power state via the Redfish call:
.. code-block:: bash
curl -d '{"ResetType":"On"}' \
-H "Content-Type: application/json" -X POST \
http://localhost:8000/redfish/v1/Systems/<uuid>/Actions/ComputerSystem.Reset
curl -d '{"ResetType":"ForceOff"}' \
-H "Content-Type: application/json" -X POST \
http://localhost:8000/redfish/v1/Systems/<uuid>/Actions/ComputerSystem.Reset
Or update their boot device:
.. code-block:: bash
curl -d '{"Boot":{"BootSourceOverrideTarget":"Pxe"}}' \
-H "Content-Type: application/json" -X PATCH \
http://localhost:8000/redfish/v1/Systems/<uuid>
curl -d '{"Boot":{"BootSourceOverrideTarget":"Hdd"}}' \
-H "Content-Type: application/json" -X PATCH \
http://localhost:8000/redfish/v1/Systems/<uuid>
Filtering by allowed instances Filtering by allowed instances
++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++++

View File

@ -5,8 +5,8 @@ Using Redfish emulators
The sushy-tools package includes two emulators - static and dynamic. The sushy-tools package includes two emulators - static and dynamic.
Static emulator could be used to serve Redfish mocks in form of static Static emulator could be used to serve Redfish mocks in form of static
JSON documents. Dynamic emulator relies upon either `libvirt` or `OpenStack` JSON documents. Dynamic emulator relies upon `libvirt`, `OpenStack` or
virtualization backend to mimic baremetal nodes behind Redfish BMC. `Ironic` virtualization backend to mimic nodes behind a Redfish BMC.
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2

View File

@ -0,0 +1,9 @@
---
features:
- |
Adds new ironic driver to provide a limited emulated redfish API
to ironic nodes. This would be needed in cases where a redfish
compatible endpoint is needed but but don't have direct access
to the BMC (you only have access via Ironic) or the BMC doesn't
support redfish.

View File

@ -34,6 +34,7 @@ from sushy_tools.emulator.resources import indicators as inddriver
from sushy_tools.emulator.resources import managers as mgrdriver from sushy_tools.emulator.resources import managers as mgrdriver
from sushy_tools.emulator.resources import storage as stgdriver from sushy_tools.emulator.resources import storage as stgdriver
from sushy_tools.emulator.resources.systems import fakedriver from sushy_tools.emulator.resources.systems import fakedriver
from sushy_tools.emulator.resources.systems import ironicdriver
from sushy_tools.emulator.resources.systems import libvirtdriver from sushy_tools.emulator.resources.systems import libvirtdriver
from sushy_tools.emulator.resources.systems import novadriver from sushy_tools.emulator.resources.systems import novadriver
from sushy_tools.emulator.resources import vmedia as vmddriver from sushy_tools.emulator.resources import vmedia as vmddriver
@ -102,6 +103,7 @@ class Application(flask.Flask):
def systems(self): def systems(self):
fake = self.config.get('SUSHY_EMULATOR_FAKE_DRIVER') fake = self.config.get('SUSHY_EMULATOR_FAKE_DRIVER')
os_cloud = self.config.get('SUSHY_EMULATOR_OS_CLOUD') os_cloud = self.config.get('SUSHY_EMULATOR_OS_CLOUD')
ironic_cloud = self.config.get('SUSHY_EMULATOR_IRONIC_CLOUD')
if fake: if fake:
result = fakedriver.FakeDriver.initialize( result = fakedriver.FakeDriver.initialize(
@ -115,6 +117,14 @@ class Application(flask.Flask):
result = novadriver.OpenStackDriver.initialize( result = novadriver.OpenStackDriver.initialize(
self.config, self.logger, os_cloud)() self.config, self.logger, os_cloud)()
elif ironic_cloud:
if not ironicdriver.is_loaded:
self.logger.error('Ironic driver not loaded')
sys.exit(1)
result = ironicdriver.IronicDriver.initialize(
self.config, self.logger, ironic_cloud)()
else: else:
if not libvirtdriver.is_loaded: if not libvirtdriver.is_loaded:
self.logger.error('libvirt driver not loaded') self.logger.error('libvirt driver not loaded')
@ -850,6 +860,11 @@ def parse_args():
help='Use the fake driver. Can also be set ' help='Use the fake driver. Can also be set '
'via environmnet variable ' 'via environmnet variable '
'SUSHY_EMULATOR_FAKE_DRIVER.') 'SUSHY_EMULATOR_FAKE_DRIVER.')
backend_group.add_argument('--ironic-cloud',
type=str,
help='Ironic cloud name. Can also be set via '
'via config variable '
'SUSHY_EMULATOR_IRONIC_CLOUD.')
return parser.parse_args() return parser.parse_args()
@ -868,6 +883,9 @@ def main():
if args.libvirt_uri: if args.libvirt_uri:
app.config['SUSHY_EMULATOR_LIBVIRT_URI'] = args.libvirt_uri app.config['SUSHY_EMULATOR_LIBVIRT_URI'] = args.libvirt_uri
if args.ironic_cloud:
app.config['SUSHY_EMULATOR_IRONIC_CLOUD'] = args.ironic_cloud
if args.fake: if args.fake:
app.config['SUSHY_EMULATOR_FAKE_DRIVER'] = True app.config['SUSHY_EMULATOR_FAKE_DRIVER'] = True

View File

@ -0,0 +1,327 @@
# Copyright 2023 Red Hat, Inc.
# All Rights Reserved.
#
# 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 math
from sushy_tools.emulator import memoize
from sushy_tools.emulator.resources.systems.base import AbstractSystemsDriver
from sushy_tools import error
try:
import openstack
except ImportError:
openstack = None
is_loaded = bool(openstack)
class IronicDriver(AbstractSystemsDriver):
"""Ironic driver"""
IRONIC_POWER_ON = "power on"
IRONIC_POWER_OFF = "power off"
IRONIC_POWER_OFF_SOFT = "soft power off"
IRONIC_POWER_REBOOT = "rebooting"
IRONIC_POWER_REBOOT_SOFT = "soft rebooting"
BOOT_DEVICE_MAP = {
'Pxe': 'pxe',
'Hdd': 'disk',
'Cd': 'cdrom',
}
BOOT_DEVICE_MAP_REV = {v: k for k, v in BOOT_DEVICE_MAP.items()}
BOOT_MODE_MAP = {
'Legacy': 'bios',
'UEFI': 'uefi',
}
BOOT_MODE_MAP_REV = {v: k for k, v in BOOT_MODE_MAP.items()}
PERMANENT_CACHE = {}
@classmethod
def initialize(cls, config, logger, os_cloud, *args, **kwargs):
cls._config = config
cls._logger = logger
cls._os_cloud = os_cloud
if not hasattr(cls, "_cc"):
cls._cc = openstack.connect(cloud=cls._os_cloud)
return cls
@memoize.memoize()
def _get_node(self, identity):
try:
node = self._cc.baremetal.get_node(identity)
return node
except openstack.exceptions.ResourceNotFound:
pass
msg = ('Error finding node by UUID "%(identity)s" at ironic '
'cloud %(os_cloud)s"' % {'identity': identity,
'os_cloud': self._os_cloud})
self._logger.debug(msg)
raise error.NotFound(msg)
@memoize.memoize(permanent_cache=PERMANENT_CACHE)
def _get_properties(self, identity):
node = self._get_node(identity)
return node.properties
@memoize.memoize(permanent_cache=PERMANENT_CACHE)
def _get_driver_internal_info(self, identity):
return self._get_node(identity).driver_internal_info
@property
def driver(self):
"""Return human-friendly driver description
:returns: driver description as `str`
"""
return '<OpenStack baremetal>'
@property
def systems(self):
"""Return available computer systems
:returns: list of UUIDs representing the systems
"""
return [node.id for node in self._cc.baremetal.nodes(fields=["uuid"])]
def uuid(self, identity):
"""Get computer system UUID by name
:param identity: OpenStack node name or ID
:returns: computer system UUID
"""
node = self._get_node(identity)
return node.id
def name(self, identity):
"""Get computer system name by name
:param identity: OpenStack node name or ID
:returns: computer system name
"""
node = self._get_node(identity)
return node.name
def get_power_state(self, identity):
"""Get computer system power state
:param identity: OpenStack node name or ID
:returns: *On* or *Off*`str` or `None`
if power state can't be determined
"""
try:
node = self._get_node(identity)
except error.FishyError:
return
if node.power_state == self.IRONIC_POWER_ON:
return 'On'
return 'Off'
def set_power_state(self, identity, state):
"""Set computer system power state
:param identity: OpenStack node name or ID
:param state: string literal requesting power state transition.
Valid values are: *On*, *ForceOn*, *ForceOff*, *GracefulShutdown*,
*GracefulRestart*, *ForceRestart*, *Nmi*.
:raises: `error.FishyError` if power state can't be set
"""
node = self._get_node(identity)
if state in ('On', 'ForceOn'):
self._cc.baremetal.set_node_power_state(
node.id, self.IRONIC_POWER_ON)
elif state == 'ForceOff':
self._cc.baremetal.set_node_power_state(
node.id, self.IRONIC_POWER_OFF)
elif state == 'GracefulShutdown':
self._cc.baremetal.set_node_power_state(
node.id, self.IRONIC_POWER_OFF_SOFT)
elif state == 'GracefulRestart':
if node.power_state == self.IRONIC_POWER_ON:
self._cc.baremetal.set_node_power_state(
node.id, self.IRONIC_POWER_REBOOT_SOFT)
elif state == 'ForceRestart':
if node.power_state == self.IRONIC_POWER_ON:
self._cc.baremetal.set_node_power_state(
node.id, self.IRONIC_POWER_REBOOT)
# NOTE(etingof) can't support `state == "Nmi"` as
# openstacksdk does not seem to support that
else:
raise error.BadRequest(
'Unknown ResetType "%(state)s"' % {'state': state})
def get_boot_device(self, identity):
"""Get computer system boot device name
:param identity: OpenStack node name or ID
:returns: boot device name as `str` or `None` if device name
can't be determined. Valid values are: *Pxe*, *Hdd*, *Cd*.
"""
try:
node = self._get_node(identity)
except error.FishyError:
return
bdevice = node.get_boot_device(self._cc.baremetal).get("boot_device")
return self.BOOT_DEVICE_MAP_REV.get(bdevice)
def set_boot_device(self, identity, boot_source):
"""Set computer system boot device name
:param identity: OpenStack node name or ID
:param boot_source: string literal requesting boot device
change on the system. Valid values are: *Pxe*, *Hdd*, *Cd*.
:raises: `error.FishyError` if boot device can't be set
"""
try:
target = self.BOOT_DEVICE_MAP[boot_source]
except KeyError:
msg = ('Unknown power state requested: '
'%(boot_source)s' % {'boot_source': boot_source})
raise error.BadRequest(msg)
self._cc.baremetal.set_node_boot_device(identity, target)
def get_boot_mode(self, identity):
"""Get computer system boot mode.
:returns: either *UEFI* or *Legacy* as `str` or `None` if
current boot mode can't be determined
"""
node = self._get_node(identity)
return self.BOOT_MODE_MAP_REV.get(node.boot_mode)
def set_boot_mode(self, identity, boot_mode):
"""Set computer system boot mode.
:param boot_mode: string literal requesting boot mode
change on the system. Valid values are: *UEFI*, *Legacy*.
:raises: `error.FishyError` if boot mode can't be set
"""
# just to make sure passed identity exists
self._get_node(identity)
msg = ('The cloud driver %(driver)s does not allow changing boot '
'mode through Redfish' % {'driver': self.driver})
raise error.NotSupportedError(msg)
def get_secure_boot(self, identity):
"""Get computer system secure boot state for UEFI boot mode.
:returns: boolean of the current secure boot state
:raises: `FishyError` if the state can't be fetched
"""
node = self._get_node(identity)
return node.is_secure_boot or False
def set_secure_boot(self, identity, secure):
"""Set computer system secure boot state for UEFI boot mode.
:param secure: boolean requesting the secure boot state
:raises: `FishyError` if the can't be set
"""
msg = ('The cloud driver %(driver)s does not support changing secure '
'boot mode through Redfish' % {'driver': self.driver})
raise error.NotSupportedError(msg)
def get_total_memory(self, identity):
"""Get computer system total memory
:param identity: OpenStack node name or ID
:returns: available RAM in GiB as `int`
"""
try:
properties = self._get_properties(identity)
except error.FishyError:
return
memory_mb = properties.get("memory_mb")
if memory_mb is None:
return None
return int(math.ceil(int(memory_mb) / 1024))
def get_total_cpus(self, identity):
"""Get computer system total count of available CPUs
:param identity: OpenStack node name or ID
:returns: available CPU count as `int`
"""
try:
properties = self._get_properties(identity)
except error.FishyError:
return
cpus = properties.get("cpus")
if cpus is None:
return None
return int(cpus)
def get_nics(self, identity):
"""Get node's network interfaces
Use MAC address as network interface's id
:param identity: OpenStack node name or ID
:returns: list of dictionaries with NIC attributes (id and mac)
"""
self._get_node(identity)
macs = set()
for port in self._cc.baremetal.ports(fields=["address", "node_uuid"]):
if port["node_uuid"] == identity:
macs.add(port["address"])
return [{'id': mac, 'mac': mac}
for mac in macs]

View File

@ -0,0 +1,199 @@
# Copyright 2023 Red Hat, Inc.
# All Rights Reserved.
#
# 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 oslotest import base
from sushy_tools.emulator.resources.systems.ironicdriver import IronicDriver
from sushy_tools import error
@mock.patch.dict(IronicDriver.PERMANENT_CACHE)
class IronicDriverTestCase(base.BaseTestCase):
uuid = 'c7a5fdbd-cdaf-9455-926a-d65c16db1809'
def setUp(self):
self.ironic_patcher = mock.patch('openstack.connect', autospec=True)
self.ironic_mock = self.ironic_patcher.start()
self.node_mock = mock.Mock(id=self.uuid)
self.ironic_mock.return_value.baremetal.get_node.return_value = \
self.node_mock
# _cc is initialized on the class (not any single object) so it needs
# to be cleared between tests
if hasattr(IronicDriver, "_cc"):
del IronicDriver._cc
test_driver_class = IronicDriver.initialize(
{}, mock.MagicMock(), 'fake-cloud')
self.test_driver = test_driver_class()
super(IronicDriverTestCase, self).setUp()
def tearDown(self):
self.ironic_patcher.stop()
super(IronicDriverTestCase, self).tearDown()
def test_uuid(self):
uuid = self.test_driver.uuid(self.uuid)
self.assertEqual(self.uuid, uuid)
def test_systems(self):
node0 = mock.Mock(id='host0')
node1 = mock.Mock(id='host1')
self.ironic_mock.return_value.baremetal.nodes.return_value = [
node0, node1]
systems = self.test_driver.systems
self.assertEqual(['host0', 'host1'], systems)
def test_get_power_state_on(self):
self.node_mock.power_state = 'power on'
power_state = self.test_driver.get_power_state(self.uuid)
self.assertEqual('On', power_state)
def test_get_power_state_off(self):
self.node_mock.power_state = 'power off'
power_state = self.test_driver.get_power_state(self.uuid)
self.assertEqual('Off', power_state)
def test_set_power_state_on(self):
self.node_mock.power_state = 'power off'
self.test_driver.set_power_state(self.uuid, 'On')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'power on')
def test_set_power_state_forceon(self):
self.node_mock.power_state = 'power off'
self.test_driver.set_power_state(self.uuid, 'ForceOn')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'power on')
def test_set_power_state_forceoff(self):
self.node_mock.power_state = 'power on'
self.test_driver.set_power_state(self.uuid, 'ForceOff')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'power off')
def test_set_power_state_gracefulshutdown(self):
self.node_mock.power_state = 'power on'
self.test_driver.set_power_state(self.uuid, 'GracefulShutdown')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'soft power off')
def test_set_power_state_gracefulrestart(self):
self.node_mock.power_state = 'power on'
self.test_driver.set_power_state(self.uuid, 'GracefulRestart')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'soft rebooting')
def test_set_power_state_forcerestart(self):
self.node_mock.power_state = 'power on'
self.test_driver.set_power_state(self.uuid, 'ForceRestart')
snps = self.ironic_mock.return_value.baremetal.set_node_power_state
snps.assert_called_once_with(self.uuid, 'rebooting')
def test_get_boot_device(self):
self.node_mock.get_boot_device.return_value.get.return_value = "pxe"
boot_device = self.test_driver.get_boot_device(self.uuid)
self.assertEqual('Pxe', boot_device)
def test_set_boot_device(self):
self.test_driver.set_boot_device(self.uuid, 'Pxe')
self.ironic_mock.return_value.baremetal.set_node_boot_device.\
assert_called_once_with(self.uuid, "pxe")
def test_get_boot_mode(self):
self.node_mock.boot_mode = 'bios'
boot_mode = self.test_driver.get_boot_mode(self.uuid)
self.assertEqual('Legacy', boot_mode)
def test_set_boot_mode(self):
self.assertRaises(
error.FishyError, self.test_driver.set_boot_mode,
self.uuid, 'Legacy')
def test_get_total_memory(self):
self.node_mock.properties = {'memory_mb': '4096'}
memory = self.test_driver.get_total_memory(self.uuid)
self.assertEqual(4, memory)
def test_get_total_cpus(self):
self.node_mock.properties = {'cpus': '2'}
cpus = self.test_driver.get_total_cpus(self.uuid)
self.assertEqual(2, cpus)
def test_get_bios(self):
self.assertRaises(
error.FishyError, self.test_driver.get_bios, self.uuid)
def test_set_bios(self):
self.assertRaises(
error.FishyError,
self.test_driver.set_bios,
self.uuid,
{'attribute 1': 'value 1'})
def test_reset_bios(self):
self.assertRaises(
error.FishyError, self.test_driver.reset_bios, self.uuid)
def test_get_nics(self):
self.ironic_mock.return_value.baremetal.ports.return_value = \
[{"node_uuid": self.uuid, "address": "fa:16:3e:22:18:31"},
{"node_uuid": "dummy", "address": "dummy"}]
nics = self.test_driver.get_nics(self.uuid)
self.assertEqual([{'id': 'fa:16:3e:22:18:31',
'mac': 'fa:16:3e:22:18:31'}],
sorted(nics, key=lambda k: k['id']))
def test_get_nics_empty(self):
self.node_mock.addresses = None
self.ironic_mock.return_value.baremetal.ports.return_value = []
nics = self.test_driver.get_nics(self.uuid)
self.assertEqual([], nics)
def test_get_simple_storage_collection(self):
self.assertRaises(
error.FishyError,
self.test_driver.get_simple_storage_collection, self.uuid)
def test_get_secure_boot_off(self):
self.node_mock.is_secure_boot = False
self.assertFalse(self.test_driver.get_secure_boot(self.uuid))
def test_get_secure_boot_on(self):
self.node_mock.is_secure_boot = True
self.assertTrue(self.test_driver.get_secure_boot(self.uuid))
def test_set_secure_boot(self):
self.assertRaises(
error.NotSupportedError, self.test_driver.set_secure_boot,
self.uuid, True)