From 79cd737e65483b1772b1ccd15f5df383597bcade Mon Sep 17 00:00:00 2001 From: Derek Higgins Date: Thu, 1 Jun 2023 11:03:22 +0100 Subject: [PATCH] Add new ironic driver Add a 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. Change-Id: I9a3f6a178978ef25efe5129d8bf5f94e031d9751 --- doc/source/admin/emulator.conf | 3 + doc/source/user/dynamic-emulator.rst | 63 ++++ doc/source/user/index.rst | 4 +- .../notes/add-ironic-6f446bf16276b4dd.yaml | 9 + sushy_tools/emulator/main.py | 18 + .../resources/systems/ironicdriver.py | 327 ++++++++++++++++++ .../emulator/resources/systems/test_ironic.py | 199 +++++++++++ 7 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/add-ironic-6f446bf16276b4dd.yaml create mode 100644 sushy_tools/emulator/resources/systems/ironicdriver.py create mode 100644 sushy_tools/tests/unit/emulator/resources/systems/test_ironic.py diff --git a/doc/source/admin/emulator.conf b/doc/source/admin/emulator.conf index 5236ff45..3f8d1e9a 100644 --- a/doc/source/admin/emulator.conf +++ b/doc/source/admin/emulator.conf @@ -19,6 +19,9 @@ SUSHY_EMULATOR_AUTH_FILE = None # The OpenStack cloud ID to use. This option enables OpenStack driver. 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. SUSHY_EMULATOR_LIBVIRT_URI = u'qemu:///system' diff --git a/doc/source/user/dynamic-emulator.rst b/doc/source/user/dynamic-emulator.rst index faee7af6..d49a3fd9 100644 --- a/doc/source/user/dynamic-emulator.rst +++ b/doc/source/user/dynamic-emulator.rst @@ -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 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/" + } + + ], + "@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//Actions/ComputerSystem.Reset + + curl -d '{"ResetType":"ForceOff"}' \ + -H "Content-Type: application/json" -X POST \ + http://localhost:8000/redfish/v1/Systems//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/ + + curl -d '{"Boot":{"BootSourceOverrideTarget":"Hdd"}}' \ + -H "Content-Type: application/json" -X PATCH \ + http://localhost:8000/redfish/v1/Systems/ + + Filtering by allowed instances ++++++++++++++++++++++++++++++ diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst index f1106eb8..ba66453c 100644 --- a/doc/source/user/index.rst +++ b/doc/source/user/index.rst @@ -5,8 +5,8 @@ Using Redfish emulators The sushy-tools package includes two emulators - static and dynamic. Static emulator could be used to serve Redfish mocks in form of static -JSON documents. Dynamic emulator relies upon either `libvirt` or `OpenStack` -virtualization backend to mimic baremetal nodes behind Redfish BMC. +JSON documents. Dynamic emulator relies upon `libvirt`, `OpenStack` or +`Ironic` virtualization backend to mimic nodes behind a Redfish BMC. .. toctree:: :maxdepth: 2 diff --git a/releasenotes/notes/add-ironic-6f446bf16276b4dd.yaml b/releasenotes/notes/add-ironic-6f446bf16276b4dd.yaml new file mode 100644 index 00000000..3ac22006 --- /dev/null +++ b/releasenotes/notes/add-ironic-6f446bf16276b4dd.yaml @@ -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. diff --git a/sushy_tools/emulator/main.py b/sushy_tools/emulator/main.py index 192fb9ad..85ff043d 100755 --- a/sushy_tools/emulator/main.py +++ b/sushy_tools/emulator/main.py @@ -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 storage as stgdriver 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 novadriver from sushy_tools.emulator.resources import vmedia as vmddriver @@ -102,6 +103,7 @@ class Application(flask.Flask): def systems(self): fake = self.config.get('SUSHY_EMULATOR_FAKE_DRIVER') os_cloud = self.config.get('SUSHY_EMULATOR_OS_CLOUD') + ironic_cloud = self.config.get('SUSHY_EMULATOR_IRONIC_CLOUD') if fake: result = fakedriver.FakeDriver.initialize( @@ -115,6 +117,14 @@ class Application(flask.Flask): result = novadriver.OpenStackDriver.initialize( 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: if not libvirtdriver.is_loaded: self.logger.error('libvirt driver not loaded') @@ -810,6 +820,11 @@ def parse_args(): help='Use the fake driver. Can also be set ' 'via environmnet variable ' '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() @@ -828,6 +843,9 @@ def main(): if 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: app.config['SUSHY_EMULATOR_FAKE_DRIVER'] = True diff --git a/sushy_tools/emulator/resources/systems/ironicdriver.py b/sushy_tools/emulator/resources/systems/ironicdriver.py new file mode 100644 index 00000000..421bb339 --- /dev/null +++ b/sushy_tools/emulator/resources/systems/ironicdriver.py @@ -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 '' + + @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] diff --git a/sushy_tools/tests/unit/emulator/resources/systems/test_ironic.py b/sushy_tools/tests/unit/emulator/resources/systems/test_ironic.py new file mode 100644 index 00000000..31f92ddb --- /dev/null +++ b/sushy_tools/tests/unit/emulator/resources/systems/test_ironic.py @@ -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)