diff --git a/doc/source/user/dynamic-emulator.rst b/doc/source/user/dynamic-emulator.rst index 3e8b743e..b40946cb 100644 --- a/doc/source/user/dynamic-emulator.rst +++ b/doc/source/user/dynamic-emulator.rst @@ -150,6 +150,23 @@ Now you can run `sushy-emulator` with the updated configuration file: The images you will serve to your VMs need to be UEFI-bootable. +Settable boot image +~~~~~~~~~~~~~~~~~~~ + +The `libvirt` system emulation backend supports setting custom boot images, +so that libvirt domains (representing bare metal nodes) can boot from user +images. + +This feature enables system boot from virtual media device. + +The limitations: + +* Only ISO images are supported +* Remote libvirt hypervisor is not supported + +See *VirtualMedia* resource section for more information on how to perform +virtual media boot. + Systems resource driver: OpenStack ++++++++++++++++++++++++++++++++++ @@ -431,3 +448,116 @@ Redfish client can eject image from virtual media device: -H "Content-Type: application/json" \ -X POST \ http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd/Actions/VirtualMedia.EjectMedia + +Virtual media boot +++++++++++++++++++ + +To boot a system from a virtual media device the client first needs to figure +out which manager is responsible for the system of interest: + +.. code-block:: bash + + $ curl http://localhost:8000/redfish/v1/Systems/281c2fc3-dd34-439a-9f0f-63df45e2c998 + { + ... + "Links": { + "Chassis": [ + ], + "ManagedBy": [ + { + "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919" + } + ] + }, + ... + +Exploring the Redfish API links, the client can learn the virtual media devices +being offered: + +.. code-block:: bash + + $ curl http://localhost:8000/redfish/v1/Managers/58893887-894-2487-2389-841168418919/VirtualMedia + ... + "Members": [ + { + "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd" + }, + ... + +Knowing virtual media device name, the client can check out its present state: + +.. code-block:: bash + + $ curl http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd + { + ... + "Name": "Virtual CD", + "MediaTypes": [ + "CD", + "DVD" + ], + "Image": "", + "ImageName": "", + "ConnectedVia": "URI", + "Inserted": false, + "WriteProtected": false, + ... + +Assuming `http://localhost/var/tmp/mini.iso` URL points to a bootable UEFI or +hybrid ISO, the following Redfish REST API call will insert the image into the +virtual CD drive: + +.. code-block:: bash + + $ curl -d \ + '{"Image":"http:://localhost/var/tmp/mini.iso", "Inserted": true}' \ + -H "Content-Type: application/json" \ + -X POST \ + http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd/Actions/VirtualMedia.InsertMedia + +Querying again, the emulator should have it in the drive: + +.. code-block:: bash + + $ curl http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd + { + ... + "Name": "Virtual CD", + "MediaTypes": [ + "CD", + "DVD" + ], + "Image": "http://localhost/var/tmp/mini.iso", + "ImageName": "mini.iso", + "ConnectedVia": "URI", + "Inserted": true, + "WriteProtected": true, + ... + +Next, the node needs to be configured to boot from its local CD drive +over UEFI: + +.. code-block:: bash + + $ curl -X PATCH -H 'Content-Type: application/json' \ + -d '{ + "Boot": { + "BootSourceOverrideTarget": "Cd", + "BootSourceOverrideMode": "Uefi", + "BootSourceOverrideEnabled": "Continuous" + } + }' \ + http://localhost:8000/redfish/v1/Systems/281c2fc3-dd34-439a-9f0f-63df45e2c998 + +By this point the system will boot off the virtual CD drive when powering it on: + +.. code-block:: bash + + curl -d '{"ResetType":"On"}' \ + -H "Content-Type: application/json" -X POST \ + http://localhost:8000/redfish/v1/Systems/281c2fc3-dd34-439a-9f0f-63df45e2c998/Actions/ComputerSystem.Reset + +.. note:: + + ISO files to boot from must be UEFI-bootable, libvirtd should be running on the same + machine with sushy-emulator. diff --git a/releasenotes/notes/add-virtual-media-4a137a5fb5017031.yaml b/releasenotes/notes/add-virtual-media-4a137a5fb5017031.yaml index 089744f7..2f001383 100644 --- a/releasenotes/notes/add-virtual-media-4a137a5fb5017031.yaml +++ b/releasenotes/notes/add-virtual-media-4a137a5fb5017031.yaml @@ -10,3 +10,10 @@ features: Each Manager automatically gets its own instance of the above mentioned virtual media device collection. HTTP/S-hosted images can be inserted into and ejected from virtual media devices. + + If libvirt virtualization backend is being used, once ISO image + is inserted into the respective Manager, any System under that + Manager can boot from that image by booting from its local ``cdrom`` + over UEFI. + + The ISO images must be UEFI-bootable or hybrid. diff --git a/sushy_tools/emulator/main.py b/sushy_tools/emulator/main.py index 899f57db..7733e170 100755 --- a/sushy_tools/emulator/main.py +++ b/sushy_tools/emulator/main.py @@ -378,7 +378,7 @@ def virtual_media_insert(identity, device): system, device, boot_image=image_path, write_protected=write_protected) - except error.FishyError as ex: + except error.NotSupportedError as ex: app.logger.warning( 'System %s failed to set boot image %s on device %s: ' '%s', system, image_path, device, ex) @@ -402,7 +402,7 @@ def virtual_media_eject(identity, device): try: resources.systems.set_boot_image(system, device) - except error.FishyError as ex: + except error.NotSupportedError as ex: app.logger.warning( 'System %s failed to remove boot image from device %s: ' '%s', system, device, ex) diff --git a/sushy_tools/emulator/resources/systems/libvirtdriver.py b/sushy_tools/emulator/resources/systems/libvirtdriver.py index 0415bd56..b6c79216 100644 --- a/sushy_tools/emulator/resources/systems/libvirtdriver.py +++ b/sushy_tools/emulator/resources/systems/libvirtdriver.py @@ -15,6 +15,7 @@ from collections import namedtuple import logging +import os import uuid import xml.etree.ElementTree as ET @@ -100,11 +101,39 @@ class LibvirtDriver(AbstractSystemsDriver): } + DEVICE_TYPE_MAP = { + constants.DEVICE_TYPE_CD: 'cdrom', + constants.DEVICE_TYPE_FLOPPY: 'floppy', + } + + DEVICE_TYPE_MAP_REV = {v: k for k, v in DEVICE_TYPE_MAP.items()} + + # target device, controller ID, controller unit number + DEVICE_TARGET_MAP = { + constants.DEVICE_TYPE_FLOPPY: ('fda', 'fdc', '0'), + constants.DEVICE_TYPE_CD: ('hdc', 'ide', '1') + } + DEFAULT_BIOS_ATTRIBUTES = {"BootMode": "Uefi", "EmbeddedSata": "Raid", "NicBoot1": "NetworkBoot", "ProcTurboMode": "Enabled"} + STORAGE_POOL = 'default' + + STORAGE_POOL_XML = """ + + %(name)s + %(name)s + %(size)i + %(size)i + + %(path)s + + + +""" + @classmethod def initialize(cls, config, uri=None): cls._config = config @@ -611,7 +640,174 @@ class LibvirtDriver(AbstractSystemsDriver): :returns: a `tuple` of (boot_image, write_protected, inserted) :raises: `error.FishyError` if boot device can't be accessed """ - raise error.FishyError('Not implemented') + domain = self._get_domain(identity, readonly=True) + + tree = ET.fromstring(domain.XMLDesc()) + + device_element = tree.find('devices') + if device_element is None: + msg = ('Missing "devices" tag in the libvirt domain ' + '"%(identity)s" configuration' % {'identity': identity}) + raise error.FishyError(msg) + + for disk_element in device_element.findall('disk'): + dev_type = disk_element.attrib.get('device') + if (dev_type not in self.DEVICE_TYPE_MAP_REV or + dev_type != self.DEVICE_TYPE_MAP.get(device)): + continue + + source_element = disk_element.find('source') + if source_element is None: + continue + + boot_image = source_element.attrib.get('file') + if boot_image is None: + continue + + read_only = disk_element.find('readonly') or False + + inserted = (self.get_boot_device(identity) == + constants.DEVICE_TYPE_CD) + if inserted: + inserted = self.get_boot_mode(identity) == 'Uefi' + + return boot_image, read_only, inserted + + return '', False, False + + def _upload_image(self, domain, conn, boot_image): + pool = conn.storagePoolLookupByName(self.STORAGE_POOL) + + pool_tree = ET.fromstring(pool.XMLDesc()) + + # Find out path to images + pool_path_element = pool_tree.find('target/path') + if pool_path_element is None: + msg = ('Missing "target/path" tag in the libvirt ' + 'storage pool "%(pool)s"' + '' % {'pool': self.STORAGE_POOL}) + raise error.FishyError(msg) + + image_name = '%s-%s.img' % ( + os.path.basename(boot_image).replace('.', '-'), + domain.UUIDString()) + + image_path = os.path.join( + pool_path_element.text, image_name) + + image_size = os.stat(boot_image).st_size + + # Remove already existing volume + + volumes_names = [v.name() for v in pool.listAllVolumes()] + if image_name in volumes_names: + volume = pool.storageVolLookupByName(image_name) + volume.delete() + + # Create new volume + + volume = pool.createXML( + self.STORAGE_POOL_XML % { + 'name': image_name, 'path': image_path, + 'size': image_size}) + + # Upload image to hypervisor + + stream = conn.newStream() + volume.upload(stream, 0, image_size) + + def read_file(stream, nbytes, fl): + return fl.read(nbytes) + + stream.sendAll(read_file, open(boot_image, 'rb')) + + stream.finish() + + return image_path + + def _add_boot_image(self, domain, domain_tree, device, + boot_image, write_protected): + + identity = domain.UUIDString() + + device_element = domain_tree.find('devices') + if device_element is None: + msg = ('Missing "devices" tag in the libvirt domain ' + '"%(identity)s" configuration' % {'identity': identity}) + raise error.FishyError(msg) + + with libvirt_open(self._uri) as conn: + + image_path = self._upload_image(domain, conn, boot_image) + + try: + lv_device = self.BOOT_DEVICE_MAP[device] + + except KeyError: + raise error.FishyError( + 'Unknown device %s at %s' % (device, identity)) + + # Add disk element pointing to the boot image + + disk_element = ET.SubElement(device_element, 'disk') + disk_element.set('type', 'file') + disk_element.set('device', lv_device) + + tgt_dev, tgt_bus, tgt_unit = self.DEVICE_TARGET_MAP[device] + + target_element = ET.SubElement(disk_element, 'target') + target_element.set('dev', tgt_dev) + target_element.set('bus', tgt_bus) + + driver_element = ET.SubElement(disk_element, 'driver') + driver_element.set('name', 'qemu') + driver_element.set('type', 'raw') + + address_element = ET.SubElement(disk_element, 'address') + address_element.set('type', 'drive') + address_element.set('controller', '0') + address_element.set('bus', '0') + address_element.set('target', '0') + address_element.set('unit', tgt_unit) + + source_element = ET.SubElement(disk_element, 'source') + source_element.set('file', image_path) + + if write_protected: + ET.SubElement(disk_element, 'readonly') + + conn.defineXML(ET.tostring(domain_tree).decode('utf-8')) + + def _remove_boot_images(self, domain, domain_tree, device): + + identity = domain.UUIDString() + + device_element = domain_tree.find('devices') + if device_element is None: + msg = ('Missing "devices" tag in the libvirt domain ' + '"%(identity)s" configuration' % {'identity': identity}) + raise error.FishyError(msg) + + try: + lv_device = self.BOOT_DEVICE_MAP[device] + + except KeyError: + raise error.FishyError( + 'Unknown device %s at %s' % (device, identity)) + + device_element = domain_tree.find('devices') + if device_element is None: + msg = ('Missing "devices" tag in the libvirt domain ' + '"%(identity)s" configuration' % {'identity': identity}) + raise error.FishyError(msg) + + # First, remove all existing devices + disk_elements = device_element.findall('disk') + + for disk_element in disk_elements: + dev_type = disk_element.attrib.get('device') + if dev_type == lv_device: + device_element.remove(disk_element) def set_boot_image(self, identity, device, boot_image=None, write_protected=True): @@ -626,4 +822,20 @@ class LibvirtDriver(AbstractSystemsDriver): :raises: `error.FishyError` if boot device can't be set """ - raise error.FishyError('Not implemented') + domain = self._get_domain(identity) + + domain_tree = ET.fromstring(domain.XMLDesc()) + + self._remove_boot_images(domain, domain_tree, device) + + if boot_image: + + try: + self._add_boot_image(domain, domain_tree, device, + boot_image, write_protected) + + except libvirt.libvirtError as e: + msg = ('Error changing boot image at libvirt URI "%(uri)s": ' + '%(error)s' % {'uri': self._uri, 'error': e}) + + raise error.FishyError(msg) diff --git a/sushy_tools/emulator/resources/systems/novadriver.py b/sushy_tools/emulator/resources/systems/novadriver.py index dbd87af0..ca37e93a 100644 --- a/sushy_tools/emulator/resources/systems/novadriver.py +++ b/sushy_tools/emulator/resources/systems/novadriver.py @@ -355,7 +355,7 @@ class OpenStackDriver(AbstractSystemsDriver): :returns: a `tuple` of (boot_image, write_protected, inserted) :raises: `error.FishyError` if boot device can't be accessed """ - raise error.FishyError('Not implemented') + raise error.NotSupportedError('Not implemented') def set_boot_image(self, identity, device, boot_image=None, write_protected=True): @@ -370,4 +370,4 @@ class OpenStackDriver(AbstractSystemsDriver): :raises: `error.FishyError` if boot device can't be set """ - raise error.FishyError('Not implemented') + raise error.NotSupportedError('Not implemented') diff --git a/sushy_tools/error.py b/sushy_tools/error.py index 913517ac..7684d489 100644 --- a/sushy_tools/error.py +++ b/sushy_tools/error.py @@ -20,3 +20,7 @@ class FishyError(Exception): class AliasAccessError(FishyError): """Node access attempted via an alias, not UUID""" + + +class NotSupportedError(FishyError): + """Feature not supported by resource driver""" diff --git a/sushy_tools/tests/unit/emulator/pool.xml b/sushy_tools/tests/unit/emulator/pool.xml new file mode 100644 index 00000000..739b27ca --- /dev/null +++ b/sushy_tools/tests/unit/emulator/pool.xml @@ -0,0 +1,17 @@ + + default + 267e1242-d53f-46dd-adb3-9f3992c55f6f + 166318571520 + 13143412736 + 153175158784 + + + + /var/lib/libvirt/images + + 0711 + 0 + 0 + + + diff --git a/sushy_tools/tests/unit/emulator/resources/systems/test_libvirt.py b/sushy_tools/tests/unit/emulator/resources/systems/test_libvirt.py index 058b1621..0fc28c5b 100644 --- a/sushy_tools/tests/unit/emulator/resources/systems/test_libvirt.py +++ b/sushy_tools/tests/unit/emulator/resources/systems/test_libvirt.py @@ -341,6 +341,55 @@ class LibvirtDriverTestCase(base.BaseTestCase): # NOTE(etingof): should enforce default loader self.assertIsNone(os_element.find('loader')) + @mock.patch('libvirt.openReadOnly', autospec=True) + def test_get_boot_image(self, libvirt_mock): + with open('sushy_tools/tests/unit/emulator/domain.xml', 'r') as f: + data = f.read() + + conn_mock = libvirt_mock.return_value + domain_mock = conn_mock.lookupByUUID.return_value + domain_mock.XMLDesc.return_value = data + + image_info = self.test_driver.get_boot_image(self.uuid, 'Cd') + + expected = '/home/user/boot.iso', False, False + + self.assertEqual(expected, image_info) + + @mock.patch('sushy_tools.emulator.resources.systems.libvirtdriver' + '.os.stat', autospec=True) + @mock.patch('sushy_tools.emulator.resources.systems.libvirtdriver' + '.open') + @mock.patch('libvirt.open', autospec=True) + @mock.patch('libvirt.openReadOnly', autospec=True) + def test_set_boot_image(self, libvirt_mock, libvirt_rw_mock, + open_mock, stat_mock): + with open('sushy_tools/tests/unit/emulator/domain.xml', 'r') as f: + data = f.read() + + conn_mock = libvirt_rw_mock.return_value + domain_mock = conn_mock.lookupByUUID.return_value + domain_mock.XMLDesc.return_value = data + + pool_mock = conn_mock.storagePoolLookupByName.return_value + + with open('sushy_tools/tests/unit/emulator/pool.xml', 'r') as f: + data = f.read() + + pool_mock.XMLDesc.return_value = data + + self.test_driver.set_boot_image(self.uuid, 'Cd', '/tmp/image.iso') + + conn_mock = libvirt_rw_mock.return_value + pool_mock.listAllVolumes.assert_called_once_with() + stat_mock.assert_called_once_with('/tmp/image.iso') + pool_mock.createXML.assert_called_once_with(mock.ANY) + + volume_mock = pool_mock.createXML.return_value + volume_mock.upload.assert_called_once_with(mock.ANY, 0, mock.ANY) + + conn_mock.defineXML.assert_called_once_with(mock.ANY) + @mock.patch('libvirt.openReadOnly', autospec=True) def test_get_total_memory(self, libvirt_mock): conn_mock = libvirt_mock.return_value