From d5d476948246ca1701633fb76672e068cf33fea3 Mon Sep 17 00:00:00 2001 From: Steve Baker Date: Thu, 7 Mar 2024 11:14:30 +1300 Subject: [PATCH] Add virtual-media-boot to openstack driver The implementation does the following. On insert: - Upload the image directly to glance from the URL (long running) - Create and attach a new volume the same size as the root disk - Rebuild the server with the image, replacing the contents of the root disk - Delete the image On eject: - Assume the attached volume has been rewritten with a new image (an ISO installer or IPA) - Detach the volume - Create an image from the volume (long running) - Rebuild the server with the new image - Delete the volume - Delete the image The long running operations are performed in a background thread task. Only one long running operation (insert or eject) can be performed concurrently for each server. If a long running operation fails, the only way to feed that back to the user is by re-raising the error during the next insert/eject request for that server. The documentation is updated to describe OpenStack driver specifics. Also the Redfish spec has deprecated accessing VirtualMedia via Managers so the documentation is updated to refer via Systems. Change-Id: I24ea943325a23a06887a185801211b4a9570e284 --- doc/source/admin/emulator.conf | 5 +- doc/source/user/dynamic-emulator.rst | 63 ++-- .../vmedia-openstack-fc422b845c343fc3.yaml | 13 + sushy_tools/emulator/main.py | 4 + .../emulator/resources/systems/novadriver.py | 356 ++++++++++++++++++ sushy_tools/emulator/resources/vmedia.py | 112 +++++- sushy_tools/error.py | 7 + .../emulator/resources/systems/test_nova.py | 349 +++++++++++++++++ .../unit/emulator/resources/test_vmedia.py | 97 +++++ 9 files changed, 978 insertions(+), 28 deletions(-) create mode 100644 releasenotes/notes/vmedia-openstack-fc422b845c343fc3.yaml diff --git a/doc/source/admin/emulator.conf b/doc/source/admin/emulator.conf index 9929168e..74a668a5 100644 --- a/doc/source/admin/emulator.conf +++ b/doc/source/admin/emulator.conf @@ -86,8 +86,9 @@ SUSHY_EMULATOR_INDICATOR_LEDS = { # Manager(s) and possibly used by the System(s) if system emulation # backend supports boot image configuration. # -# If this map is not present in the configuration, the following configuration -# is used: +# This value is ignored by the OpenStack driver, which only supports the 'Cd' +# device. If this map is not present in the configuration, the following +# configuration is used for other drivers: SUSHY_EMULATOR_VMEDIA_DEVICES = { u'Cd': { u'Name': 'Virtual CD', diff --git a/doc/source/user/dynamic-emulator.rst b/doc/source/user/dynamic-emulator.rst index b0f5c2bb..d803ec3d 100644 --- a/doc/source/user/dynamic-emulator.rst +++ b/doc/source/user/dynamic-emulator.rst @@ -292,7 +292,7 @@ Redfish *Systems*: "Members": [ { - "@odata.id": "/redfish/v1/Systems/vbmc-node" + "@odata.id": "/redfish/v1/Systems/8dbe91da-4002-4d61-a56d-1a00fc61c35d" } ], @@ -528,7 +528,8 @@ to *Cd* and boot mode to *Uefi* will cause the system to boot from virtual media image. User can change virtual media devices and their properties through -emulator configuration: +emulator configuration (except for the OpenStack driver which only +supports *Cd*): .. code-block:: python @@ -549,11 +550,11 @@ emulator configuration: } } -Virtual Media resource will be revealed when querying Manager resource: +Virtual Media resource will be revealed when querying System resource: .. code-block:: bash - curl -L http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia + curl -L http://localhost:8000/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia { "@odata.type": "#VirtualMediaCollection.VirtualMediaCollection", "Name": "Virtual Media Services", @@ -562,16 +563,16 @@ Virtual Media resource will be revealed when querying Manager resource: "Members": [ { - "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd" + "@odata.id": "/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd" }, { - "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Floppy" + "@odata.id": "/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Floppy" } ], "@odata.context": "/redfish/v1/$metadata#VirtualMediaCollection.VirtualMediaCollection", - "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia", + "@odata.id": "/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia", "@Redfish.Copyright": "Copyright 2014-2017 Distributed Management Task Force, Inc. (DMTF). For the full DMTF copyright policy, see http://www.dmtf.org/about/policies/copyright." } @@ -579,22 +580,19 @@ Redfish client can insert a HTTP-based image into the virtual device: .. code-block:: bash - curl -d '{"Image":"http://localhost.localdomain/mini.iso",\ - "Inserted": true}' \ + curl -d '{"Image": "http://localhost.localdomain/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 + http://localhost:8000/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd/Actions/VirtualMedia.InsertMedia -.. note:: +On insert the OpenStack driver will: - All systems being managed by this manager and booting from their - corresponding removable media device (e.g. cdrom or fd) will boot the - image inserted into manager's virtual media device. - -.. warning:: - - System boot from virtual media only works if *System* resource emulation - driver supports setting boot image. +* Upload the image directly to glance from the URL (long running) +* Store the URL, image ID and volume ID in server metadata properties + `sushy-tools-image-url`, `sushy-tools-import-image`, `sushy-tools-volume` +* Create and attach a new volume the same size as the root disk +* Rebuild the server with the image, replacing the contents of the root disk +* Delete the image Redfish client can eject image from virtual media device: @@ -603,7 +601,17 @@ Redfish client can eject image from virtual media device: curl -d '{}' \ -H "Content-Type: application/json" \ -X POST \ - http://localhost:8000/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd/Actions/VirtualMedia.EjectMedia + http://localhost:8000/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd/Actions/VirtualMedia.EjectMedia + +On eject the OpenStack driver will: + +* Assume the attached volume has been rewritten with a new image (an ISO installer or IPA) +* Detach the volume +* Create an image from the volume (long running) +* Store the volume image ID in server metadata property `sushy-tools-volume-image` +* Rebuild the server with the new image +* Delete the volume +* Delete the image Virtual media boot ++++++++++++++++++ @@ -632,11 +640,11 @@ being offered: .. code-block:: bash - $ curl http://localhost:8000/redfish/v1/Managers/58893887-894-2487-2389-841168418919/VirtualMedia + $ curl http://localhost:8000/redfish/v1/Systems/58893887-894-2487-2389-841168418919/VirtualMedia ... "Members": [ { - "@odata.id": "/redfish/v1/Managers/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd" + "@odata.id": "/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd" }, ... @@ -644,7 +652,7 @@ 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 + $ curl http://localhost:8000/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd { ... "Name": "Virtual CD", @@ -669,13 +677,13 @@ virtual CD drive: '{"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 + http://localhost:8000/redfish/v1/Systems/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 + $ curl http://localhost:8000/redfish/v1/Systems/58893887-8974-2487-2389-841168418919/VirtualMedia/Cd { ... "Name": "Virtual CD", @@ -705,6 +713,11 @@ over UEFI: }' \ http://localhost:8000/redfish/v1/Systems/281c2fc3-dd34-439a-9f0f-63df45e2c998 +.. note:: + + With the OpenStack driver the boot source is changed during insert and eject, so setting + `BootSourceOverrideTarget` to `Cd` or `Hdd` has no effect. + By this point the system will boot off the virtual CD drive when powering it on: .. code-block:: bash diff --git a/releasenotes/notes/vmedia-openstack-fc422b845c343fc3.yaml b/releasenotes/notes/vmedia-openstack-fc422b845c343fc3.yaml new file mode 100644 index 00000000..2dd58149 --- /dev/null +++ b/releasenotes/notes/vmedia-openstack-fc422b845c343fc3.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + The openstack driver now supports insert and eject of virtual media. On + insert a new empty volume is created and attached to the server and the + server is rebuilt with the that image. On eject it is assumed that the + attached volume has been rewritten with bootable image data. The volume is + detached and uploaded as an image, then the server is rebuilt with that + image. + + Both insert and delete results in the root disk being wiped and replaced + with the contents of an image, so this should not be used in any scenario + where the root disk data needs to be retained. \ No newline at end of file diff --git a/sushy_tools/emulator/main.py b/sushy_tools/emulator/main.py index 320fadbd..b3a26cab 100755 --- a/sushy_tools/emulator/main.py +++ b/sushy_tools/emulator/main.py @@ -170,6 +170,10 @@ class Application(flask.Flask): @property @memoize.memoize() def vmedia(self): + os_cloud = self.config.get('SUSHY_EMULATOR_OS_CLOUD') + if os_cloud: + return vmddriver.OpenstackDriver(self.config, self.logger, + self.systems) return vmddriver.StaticDriver(self.config, self.logger) @property diff --git a/sushy_tools/emulator/resources/systems/novadriver.py b/sushy_tools/emulator/resources/systems/novadriver.py index a9c64a6b..34ed3f1c 100644 --- a/sushy_tools/emulator/resources/systems/novadriver.py +++ b/sushy_tools/emulator/resources/systems/novadriver.py @@ -13,7 +13,12 @@ # License for the specific language governing permissions and limitations # under the License. +import base64 +from concurrent import futures import math +import os +import time +from urllib import parse as urlparse from sushy_tools.emulator import memoize from sushy_tools.emulator.resources.systems.base import AbstractSystemsDriver @@ -28,6 +33,8 @@ except ImportError: is_loaded = bool(openstack) +FUTURES = {} + class OpenStackDriver(AbstractSystemsDriver): """OpenStack driver""" @@ -58,6 +65,7 @@ class OpenStackDriver(AbstractSystemsDriver): cls._os_cloud = os_cloud cls._cc = openstack.connect(cloud=os_cloud) + cls._executor = futures.ThreadPoolExecutor(max_workers=4) return cls @@ -95,6 +103,18 @@ class OpenStackDriver(AbstractSystemsDriver): def _set_server_metadata(self, identity, metadata): self._cc.compute.set_server_metadata(identity, metadata) + @property + def _futures(self): + return FUTURES + + @property + def connection(self): + """Return openstack connection + + :returns: Connection object + """ + return self._cc + @property def driver(self): """Return human-friendly driver description @@ -358,3 +378,339 @@ class OpenStackDriver(AbstractSystemsDriver): 'Could not find MAC address in %s', adr) return [{'id': mac, 'mac': mac} for mac in macs] + + def get_boot_image(self, identity, device): + """Get backend VM boot image info + + :param identity: node name or ID + :param device: device type (from + `sushy_tools.emulator.constants`) + :returns: a `tuple` of (boot_image, write_protected, inserted) + :raises: `error.FishyError` if boot device can't be accessed + """ + instance = self._get_instance(identity) + return instance.image.id, False, True + + def set_boot_image(self, identity, device, boot_image=None, + write_protected=True): + """Set backend VM boot image + + :param identity: node name or ID + :param device: device type (from + `sushy_tools.emulator.constants`) + :param boot_image: ID of the image, or `None` to switch to + boot from volume + :param write_protected: expose media as read-only or writable + + :raises: `error.FishyError` if boot device can't be set + """ + instance = self._get_instance(identity) + + if instance.image.id == boot_image: + msg = ('Image %(identity)s already has image %(boot_image)s. ' + 'Skipping rebuild.' % {'identity': identity, + 'boot_image': boot_image}) + self._logger.debug(msg) + + elif boot_image is None: + self._logger.debug( + 'Creating task to upload volume and rebuild for %(identity)s' % + {'identity': identity}) + self._submit_future( + True, self._rebuild_with_volume_image, identity) + else: + self._logger.debug( + 'Creating task to finish import and rebuild for %(identity)s' % + {'identity': identity}) + self._submit_future( + True, self._rebuild_with_imported_image, identity, boot_image) + + def insert_image(self, identity, image_url): + self._logger.debug( + 'Creating task to insert image for %(identity)s' % + {'identity': identity}) + return self._submit_future( + False, self._insert_image, identity, image_url) + + def _insert_image(self, identity, image_url): + parsed_url = urlparse.urlparse(image_url) + local_file = os.path.basename(parsed_url.path) + unique = base64.urlsafe_b64encode(os.urandom(6)).decode('utf-8') + image_attrs = { + 'name': '%s %s' % (local_file, unique), + 'disk_format': 'raw', + 'container_format': 'bare', + 'visibility': 'private' + } + image = None + volume = None + try: + + # Create image, and begin importing. Waiting for import to complete + # will be part of a long-running operation + image = self._cc.image.create_image(**image_attrs) + self._logger.debug( + 'Importing image %(url)s for %(identity)s' % + {'identity': identity, 'url': image_url}) + self._cc.image.import_image(image, method='web-download', + uri=image_url) + self._cc.set_server_metadata( + identity, { + 'sushy-tools-import-image': image.id, + 'sushy-tools-image-url': image_url + }) + + # Create an empty volume the size of the root disk which will be + # attached during the long-running operation + self._logger.debug( + 'Creating volume for %(identity)s' % + {'identity': identity}) + server = self._cc.compute.get_server(identity) + volume = self._cc.block_storage.create_volume( + size=server.flavor.disk, + name=server.name) + self._cc.set_server_metadata( + identity, {'sushy-tools-volume': volume.id}) + except Exception as ex: + msg = 'Failed insert image from URL %s: %s' % (image_url, ex) + self._logger.exception(msg) + self._attempt_delete_image_volume( + image, volume, identity, + 'sushy-tools-import-image', 'sushy-tools-volume') + if not isinstance(ex, error.FishyError): + ex = error.FishyError(msg) + raise ex + + return image.id, image.name + + def eject_image(self, identity): + self._logger.debug( + 'Creating task to eject image for %(identity)s' % + {'identity': identity}) + self._submit_future(False, self._eject_image, identity) + + def _eject_image(self, identity): + try: + # Assume that the inserted image wrote a new image to the volume, + # so convert the volume to an image and rebuild with that image + # to switch + server = self._cc.compute.get_server(identity) + image_url = server.metadata.get('sushy-tools-image-url') + volume_id = server.metadata.get('sushy-tools-volume') + volume = self._cc.block_storage.get_volume( + volume_id) + + if volume.status in ('detaching', 'available'): + self._logger.debug( + 'Volume %(volume)s already detaching or ' + 'detached from server %(identity)s' % { + 'identity': identity, 'volume': volume}) + else: + self._logger.debug( + 'Deleting attachment for volume %(volume)s and server ' + '%(identity)s' % {'identity': identity, 'volume': volume}) + # Delete the attachment so the image can be created from the + # volume + self._cc.compute.delete_volume_attachment(identity, volume) + + self._logger.debug( + 'Waiting for volume %(volume)s to be available' % + {'volume': volume}) + while volume.status in ('queued', 'detaching', 'in-use'): + time.sleep(1) + volume = self._cc.block_storage.get_volume(volume) + if volume.status != 'available': + raise error.FishyError( + 'Volume detachment resulted in status %s' % + volume.status) + + image_attrs = { + 'volume': volume, + 'image_name': volume.name, + 'disk_format': 'raw', + 'container_format': 'bare', + 'visibility': 'private', + } + + self._logger.debug( + 'Creating image from volume %(volume)s for server ' + '%(identity)s' % + {'identity': identity, 'volume': volume}) + upload = self._cc.block_storage.upload_volume_to_image( + **image_attrs) + image_id = upload['image_id'] + self._cc.set_server_metadata( + identity, {'sushy-tools-volume-image': image_id}) + + except Exception as ex: + msg = 'Failed ejecting image %s: %s' % (image_url, ex) + self._logger.exception(msg) + if not isinstance(ex, error.FishyError): + ex = error.FishyError(msg) + raise ex + + def _attempt_delete_image_volume(self, image, volume, identity, + *metadata_keys): + if volume: + try: + self._logger.debug('Deleting volume %(volume)s' % + {'volume': volume}) + self._cc.block_storage.delete_volume(volume) + except Exception: + pass + if image: + try: + self._logger.debug('Deleting image %(image)s' % + {'image': image}) + self._cc.delete_image(image) + except Exception: + pass + if identity and metadata_keys: + try: + self._cc.delete_server_metadata(identity, metadata_keys) + except Exception: + pass + + def _submit_future(self, run_async, fn, identity, *args, **kwargs): + future = self._futures.get(identity, None) + if future is not None: + if future.running(): + raise error.Conflict( + 'An insert or eject operation is already in progress for ' + '%(identity)s' % {'identity': identity}) + + ex = future.exception() + if ex is not None: + # A previous operation failed, and the server may be in an + # unknown state. Raise the previous error as an error for + # this operation. + del self._futures[identity] + raise ex + + future = self._executor.submit(fn, identity, *args, **kwargs) + self._futures[identity] = future + if run_async: + return + ex = future.exception() + if ex is not None: + raise ex + return future.result() + + def _rebuild_with_imported_image(self, identity, image_id): + try: + image = self._cc.image.get_image(image_id) + server = self._cc.compute.get_server(identity) + image_url = server.metadata.get('sushy-tools-image-url') + volume_id = server.metadata.get('sushy-tools-volume') + volume = self._cc.block_storage.get_volume(volume_id) + + # Wait for volume to be available + while volume.status == 'creating': + time.sleep(1) + volume = self._cc.block_storage.get_volume(volume) + if volume.status not in 'available': + raise error.FishyError( + 'Volume creation resulted in status %s' % + volume.status) + self._logger.debug( + 'Attaching volume %(volume)s and server %(identity)s' % + {'identity': identity, 'volume': volume}) + self._cc.compute.create_volume_attachment( + identity, volume, + delete_on_termination=True) + while volume.status in ('available', 'reserved', 'attaching'): + time.sleep(1) + volume = self._cc.block_storage.get_volume(volume) + if volume.status not in 'in-use': + raise error.FishyError( + 'Volume attachment resulted in status %s' % + volume.status) + + # Wait for image to be imported + while image.status in ('queued', 'importing'): + time.sleep(1) + image = self._cc.image.get_image(image) + if image.status != 'active': + raise error.FishyError('Image import ended with status %s' % + image.status) + + self._logger.debug( + 'Rebuilding %(identity)s with image %(image)s' % + {'identity': identity, 'image': image.id}) + server = self._cc.compute.rebuild_server(identity, image.id) + while server.status == 'REBUILD': + server = self._cc.compute.get_server(identity) + time.sleep(1) + if server.status != 'ACTIVE': + raise error.FishyError('Server rebuild attempt resulted in ' + 'status %s' % server.status) + self._logger.debug( + 'Rebuild %(identity)s complete' % {'identity': identity}) + + except Exception as ex: + msg = 'Failed insert image from URL %s: %s' % (image_url, ex) + self._logger.exception(msg) + self._attempt_delete_image_volume( + None, volume_id, identity, 'sushy-tools-volume') + if not isinstance(ex, error.FishyError): + ex = error.FishyError(msg) + raise ex + finally: + self._attempt_delete_image_volume( + image_id, None, identity, 'sushy-tools-image') + + def _rebuild_with_volume_image(self, identity): + try: + + server = self._cc.compute.get_server(identity) + image_id = server.metadata.get('sushy-tools-volume-image') + volume_id = server.metadata.get('sushy-tools-volume') + image_url = server.metadata.get('sushy-tools-image-url') + + if not image_id or not volume_id: + # Nothing to do + return + + image = self._cc.image.get_image(image_id) + while image.status in ('queued', 'uploading', 'saving'): + time.sleep(1) + image = self._cc.image.get_image(image) + if image.status != 'active': + raise error.FishyError( + 'Image import ended with status %s' % image.status) + + self._logger.debug( + 'Rebuilding %(identity)s with image %(image)s' % + {'identity': identity, 'image': image.id}) + server = self._cc.compute.rebuild_server(identity, image.id) + while server.status == 'REBUILD': + server = self._cc.compute.get_server(identity) + time.sleep(1) + if server.status != 'ACTIVE': + raise error.FishyError( + 'Server rebuild attempt resulted in status %s' + % server.status) + self._logger.debug( + 'Rebuild %(identity)s complete' % {'identity': identity}) + + # Wait for the volume to be back into a state which can be deleted + volume = self._cc.block_storage.get_volume( + volume_id) + + while volume.status == 'uploading': + time.sleep(1) + volume = self._cc.block_storage.get_volume(volume) + if volume.status != 'available': + raise error.FishyError( + 'Volume upload resulted in status %s' % volume.status) + + except Exception as ex: + msg = 'Failed ejecting image %s: %s' % (image_url, ex) + self._logger.exception(msg) + if not isinstance(ex, error.FishyError): + ex = error.FishyError(msg) + raise ex + finally: + self._attempt_delete_image_volume( + image_id, volume_id, identity, + 'sushy-tools-volume-image', 'sushy-tools-volume') diff --git a/sushy_tools/emulator/resources/vmedia.py b/sushy_tools/emulator/resources/vmedia.py index 70d09eb8..1f783642 100644 --- a/sushy_tools/emulator/resources/vmedia.py +++ b/sushy_tools/emulator/resources/vmedia.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import abc import collections import os import re @@ -37,7 +38,7 @@ Certificate = collections.namedtuple( _CERT_ID = "Default" -class StaticDriver(base.DriverBase): +class BaseDriver(base.DriverBase): """Redfish virtual media simulator.""" def __init__(self, config, logger): @@ -196,6 +197,34 @@ class StaticDriver(base.DriverBase): del device_info["Certificate"] self._devices[(identity, device)] = device_info + @abc.abstractmethod + def insert_image(self, identity, device, image_url, + inserted=True, write_protected=True, + username=None, password=None): + """Upload, remove or insert virtual media + + :param identity: parent resource ID + :param device: device name + :param image_url: URL to ISO image to place into `device` or `None` + to eject currently present media + :param inserted: treat currently present media as inserted or not + :param write_protected: prevent write access the inserted media + :raises: `FishyError` if image can't be manipulated + """ + + @abc.abstractmethod + def eject_image(self, identity, device): + """Eject virtual media image + + :param identity: parent resource ID + :param device: device name + :raises: `FishyError` if image can't be manipulated + """ + + +class StaticDriver(BaseDriver): + """Redfish virtual media simulator for local image storage.""" + def _write_from_response(self, image_url, rsp, tmp_file): with open(tmp_file.name, 'wb') as fl: for chunk in rsp.iter_content(chunk_size=8192): @@ -344,3 +373,84 @@ class StaticDriver(base.DriverBase): except FileNotFoundError: # Ignore error as we are trying to remove the file anyway pass + + +class OpenstackDriver(BaseDriver): + """Redfish virtual media simulator for openstack image storage.""" + + def __init__(self, config, logger, driver): + super().__init__(config, logger) + # Only support 'Cd', ignore SUSHY_EMULATOR_VMEDIA_DEVICES + self._device_types = { + 'Cd': { + 'Name': 'Virtual CD', + 'MediaTypes': [ + 'CD', + 'DVD' + ] + } + } + self._driver = driver + + @property + def driver(self): + """Return human-friendly driver description + + :returns: driver description as `str` + """ + return self._driver.driver + + def insert_image(self, identity, device, image_url, + inserted=True, write_protected=True, + username=None, password=None): + """Upload, remove or insert virtual media + + :param identity: parent resource ID + :param device: device name + :param image_url: URL to ISO image to place into `device` or `None` + to eject currently present media + :param inserted: treat currently present media as inserted or not + :param write_protected: prevent write access the inserted media + :raises: `FishyError` if image can't be manipulated + """ + device_info = self._get_device(identity, device) + verify_media_cert = device_info.get( + 'Verify', + # NOTE(dtantsur): it's de facto standard for Redfish to default + # to no certificate validation. + self._config.get('SUSHY_EMULATOR_VMEDIA_VERIFY_SSL', False)) + if verify_media_cert: + msg = ('The cloud driver %(driver)s does not support inserting an ' + 'image with a custom download certificate' % + {'driver': self.driver}) + raise error.NotSupportedError(msg) + + auth = (username, password) if (username and password) else None + if auth: + msg = ('The cloud driver %(driver)s does not support inserting an ' + 'image with download credentials' % {'driver': self.driver}) + raise error.NotSupportedError(msg) + + image_id, image_name = self._driver.insert_image( + identity, image_url) + + device_info['Image'] = image_url + device_info['ImageName'] = image_name + device_info['Inserted'] = inserted + device_info['WriteProtected'] = write_protected + + self._devices.update({(identity, device): device_info}) + return image_id + + def eject_image(self, identity, device): + """Eject virtual media image + + :param identity: parent resource ID + :param device: device name + :raises: `FishyError` if image can't be manipulated + """ + device_info = self._get_device(identity, device) + self._driver.eject_image(identity) + device_info['Image'] = '' + device_info['ImageName'] = '' + device_info['Inserted'] = False diff --git a/sushy_tools/error.py b/sushy_tools/error.py index e22f1669..7404d689 100644 --- a/sushy_tools/error.py +++ b/sushy_tools/error.py @@ -52,3 +52,10 @@ class FeatureNotAvailable(NotFound): def __init__(self, feature, code=404): super().__init__(f"Feature {feature} not available", code=code) + + +class Conflict(FishyError): + """Conflict with current state of the resource.""" + + def __init__(self, msg, code=409): + super().__init__(msg, code) diff --git a/sushy_tools/tests/unit/emulator/resources/systems/test_nova.py b/sushy_tools/tests/unit/emulator/resources/systems/test_nova.py index e6aa7ba1..c220f9a1 100644 --- a/sushy_tools/tests/unit/emulator/resources/systems/test_nova.py +++ b/sushy_tools/tests/unit/emulator/resources/systems/test_nova.py @@ -12,6 +12,8 @@ # 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 base64 +import time from unittest import mock from munch import Munch @@ -30,6 +32,7 @@ class NovaDriverTestCase(base.BaseTestCase): def setUp(self): self.nova_patcher = mock.patch('openstack.connect', autospec=True) self.nova_mock = self.nova_patcher.start() + self._cc = self.nova_mock.return_value test_driver_class = OpenStackDriver.initialize( {}, mock.MagicMock(), 'fake-cloud') @@ -305,3 +308,349 @@ class NovaDriverTestCase(base.BaseTestCase): self.assertRaises(error.NotSupportedError, self.test_driver.set_http_boot_uri, None) + + @mock.patch.object(base64, 'urlsafe_b64encode', autospec=True) + def test_insert_image(self, mock_b64e): + mock_b64e.return_value = b'0hIwh_vN' + mock_server = mock.Mock() + mock_server.flavor.disk = 20 + mock_server.name = 'node01' + queued_image = mock.Mock(id='aaa-bbb') + self._cc.image.create_image.return_value = queued_image + self._cc.compute.get_server.return_value = mock_server + self._cc.block_storage.create_volume.return_value = mock.Mock( + id='ccc-ddd') + + image_id, image_name = self.test_driver.insert_image( + self.uuid, 'http://fish.it/red.iso') + + self._cc.image.create_image.assert_called_once_with( + name='red.iso 0hIwh_vN', disk_format='raw', + container_format='bare', visibility='private') + self._cc.compute.get_server.assert_called_once_with(self.uuid) + self._cc.block_storage.create_volume.assert_called_once_with( + size=20, name='node01') + self._cc.image.import_image.assert_called_once_with( + queued_image, method='web-download', uri='http://fish.it/red.iso') + + self.assertEqual('aaa-bbb', image_id) + + def test_insert_image_fail(self): + mock_server = mock.Mock() + mock_server.flavor.disk = 20 + mock_server.name = 'node01' + self._cc.image.create_image.return_value = mock.Mock(id='aaa-bbb') + self._cc.compute.get_server.return_value = mock_server + self._cc.block_storage.create_volume.return_value = mock.Mock( + id='ccc-ddd') + + self._cc.image.create_image.side_effect = Exception('ouch') + + e = self.assertRaises( + error.FishyError, self.test_driver.insert_image, + self.uuid, 'http://fish.it/red.iso') + self.assertEqual( + 'Failed insert image from URL http://fish.it/red.iso: ouch', + str(e)) + + def test_insert_image_future_running(self): + + mock_future = mock.Mock() + mock_future.running.return_value = True + self.test_driver._futures[self.uuid] = mock_future + e = self.assertRaises( + error.FishyError, self.test_driver.insert_image, + self.uuid, 'http://fish.it/red.iso') + self.assertEqual( + 'An insert or eject operation is already in progress for ' + 'c7a5fdbd-cdaf-9455-926a-d65c16db1809', str(e)) + + def test_insert_image_future_exception(self): + + mock_future = mock.Mock() + mock_future.running.return_value = False + mock_future.exception.return_value = error.FishyError('ouch') + self.test_driver._futures[self.uuid] = mock_future + e = self.assertRaises( + error.FishyError, self.test_driver.insert_image, + self.uuid, 'http://fish.it/red.iso') + self.assertEqual('ouch', str(e)) + + @mock.patch.object(time, 'sleep', autospec=True) + def test_eject_image(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.compute.get_server.return_value = mock_server + + available_volume = mock.Mock() + available_volume.id = 'ccc-ddd' + available_volume.status = 'available' + available_volume.name = self.uuid + in_use_volume = mock.Mock(id='ccc-ddd', status='in-use') + self._cc.block_storage.get_volume.side_effect = [ + in_use_volume, + mock.Mock(id='ccc-ddd', status='queued'), + mock.Mock(id='ccc-ddd', status='detaching'), + available_volume, + mock.Mock(id='ccc-ddd', status='uploading'), + available_volume + ] + self._cc.block_storage.upload_volume_to_image.return_value = { + 'image_id': 'aaa-bbb'} + + self.test_driver.eject_image(self.uuid) + + self._cc.compute.delete_volume_attachment(self.uuid, in_use_volume) + self._cc.block_storage.upload_volume_to_image.assert_called_once_with( + volume=available_volume, image_name=self.uuid, disk_format='raw', + container_format='bare', visibility='private') + + @mock.patch.object(time, 'sleep', autospec=True) + def test_eject_image_error_detach(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.compute.get_server.return_value = mock_server + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='in-use'), + mock.Mock(id='ccc-ddd', status='queued'), + mock.Mock(id='ccc-ddd', status='detaching'), + mock.Mock(id='ccc-ddd', status='error'), + ] + + e = self.assertRaises( + error.FishyError, self.test_driver.eject_image, + self.uuid) + self.assertEqual('Volume detachment resulted in status error', str(e)) + + self._cc.delete_image.assert_not_called() + self._cc.block_storage.delete_volume.assert_not_called() + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_imported_image(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.compute.get_server.return_value = mock_server + queued_image = mock.Mock(id='aaa-bbb', status='queued') + self._cc.image.get_image.side_effect = [ + queued_image, + mock.Mock(id='aaa-bbb', status='importing'), + mock.Mock(id='aaa-bbb', status='active'), + ] + available_volume = mock.Mock(id='ccc-ddd', status='available') + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='creating'), + available_volume, + mock.Mock(id='ccc-ddd', status='reserved'), + mock.Mock(id='ccc-ddd', status='attaching'), + mock.Mock(id='ccc-ddd', status='in-use') + ] + self._cc.compute.rebuild_server.return_value = mock.Mock( + status='REBUILD') + self._cc.compute.get_server.side_effect = [ + mock.Mock(status='REBUILD'), + mock.Mock(status='ACTIVE'), + ] + + self.test_driver._rebuild_with_imported_image( + self.uuid, 'aaa-bbb') + + self._cc.compute.create_volume_attachment.assert_called_once_with( + self.uuid, available_volume, delete_on_termination=True) + self._cc.compute.rebuild_server.assert_called_once_with( + self.uuid, 'aaa-bbb') + self._cc.delete_image.assert_called_once_with('aaa-bbb') + self._cc.block_storage.delete_volume.assert_not_called() + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_imported_imaged_error_image(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='creating'), + mock.Mock(id='ccc-ddd', status='available'), + mock.Mock(id='ccc-ddd', status='reserved'), + mock.Mock(id='ccc-ddd', status='attaching'), + mock.Mock(id='ccc-ddd', status='in-use') + ] + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='importing'), + mock.Mock(id='aaa-bbb', status='error'), + ] + e = self.assertRaises( + error.FishyError, self.test_driver._rebuild_with_imported_image, + self.uuid, 'aaa-bbb') + self.assertEqual('Image import ended with status error', str(e)) + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_imported_image_error_volume(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.compute.get_server.return_value = mock_server + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='importing'), + mock.Mock(id='aaa-bbb', status='active'), + ] + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='creating'), + mock.Mock(id='ccc-ddd', status='reserved'), + mock.Mock(id='ccc-ddd', status='error') + ] + e = self.assertRaises( + error.FishyError, self.test_driver._rebuild_with_imported_image, + self.uuid, 'aaa-bbb') + self.assertEqual('Volume creation resulted in status reserved', str(e)) + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_imported_image_error_rebuild(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd' + } + self._cc.compute.get_server.return_value = mock_server + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='importing'), + mock.Mock(id='aaa-bbb', status='active'), + ] + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='creating'), + mock.Mock(id='ccc-ddd', status='available'), + mock.Mock(id='ccc-ddd', status='reserved'), + mock.Mock(id='ccc-ddd', status='attaching'), + mock.Mock(id='ccc-ddd', status='in-use') + ] + self._cc.compute.rebuild_server.return_value = mock.Mock( + status='REBUILD') + self._cc.compute.get_server.side_effect = [ + mock.Mock(status='REBUILD'), + mock.Mock(status='ERROR'), + ] + e = self.assertRaises( + error.FishyError, self.test_driver._rebuild_with_imported_image, + self.uuid, 'aaa-bbb') + self.assertEqual( + 'Server rebuild attempt resulted in status ERROR', str(e)) + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_volume_image(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd', + 'sushy-tools-volume-image': 'aaa-bbb' + } + mock_server.status = 'ACTIVE' + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='uploading'), + mock.Mock(id='aaa-bbb', status='saving'), + mock.Mock(id='aaa-bbb', status='active'), + ] + self._cc.compute.rebuild_server.return_value = mock.Mock( + status='REBUILD') + self._cc.compute.get_server.side_effect = [ + mock_server, + mock.Mock(status='REBUILD'), + mock.Mock(status='ACTIVE'), + ] + self._cc.block_storage.get_volume.side_effect = [ + mock.Mock(id='ccc-ddd', status='uploading'), + mock.Mock(id='ccc-ddd', status='available') + ] + + self.test_driver._rebuild_with_volume_image( + self.uuid) + + self._cc.compute.rebuild_server.assert_called_once_with( + self.uuid, 'aaa-bbb') + self._cc.delete_image.assert_called_once_with('aaa-bbb') + self._cc.block_storage.delete_volume.assert_called_once_with('ccc-ddd') + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_volume_image_error_upload(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd', + 'sushy-tools-volume-image': 'aaa-bbb' + } + mock_server.status = 'ACTIVE' + self._cc.compute.get_server.return_value = mock_server + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='uploading'), + mock.Mock(id='aaa-bbb', status='saving'), + mock.Mock(id='aaa-bbb', status='error'), + ] + + e = self.assertRaises( + error.FishyError, self.test_driver._rebuild_with_volume_image, + self.uuid) + self.assertEqual('Image import ended with status error', str(e)) + + self._cc.compute.rebuild_server.assert_not_called() + self._cc.delete_image.assert_called_once_with('aaa-bbb') + self._cc.block_storage.delete_volume.assert_called_once_with('ccc-ddd') + + @mock.patch.object(time, 'sleep', autospec=True) + def test__rebuild_with_volume_image_error_rebuild(self, mock_sleep): + mock_server = mock.Mock() + mock_server.name = 'node01' + mock_server.metadata = { + 'sushy-tools-image-url': 'http://fish.it/red.iso', + 'sushy-tools-volume': 'ccc-ddd', + 'sushy-tools-volume-image': 'aaa-bbb' + } + mock_server.status = 'ACTIVE' + self._cc.image.get_image.side_effect = [ + mock.Mock(id='aaa-bbb', status='queued'), + mock.Mock(id='aaa-bbb', status='uploading'), + mock.Mock(id='aaa-bbb', status='saving'), + mock.Mock(id='aaa-bbb', status='active'), + ] + self._cc.block_storage.upload_volume_to_image.return_value = { + 'image_id': 'aaa-bbb'} + self._cc.compute.rebuild_server.return_value = mock.Mock( + status='REBUILD') + self._cc.compute.get_server.side_effect = [ + mock_server, + mock.Mock(status='REBUILD'), + mock.Mock(status='ERROR'), + ] + + e = self.assertRaises( + error.FishyError, self.test_driver._rebuild_with_volume_image, + self.uuid) + self.assertEqual( + 'Server rebuild attempt resulted in status ERROR', str(e)) + + self._cc.compute.rebuild_server.assert_called_once_with( + self.uuid, 'aaa-bbb') + self._cc.delete_image.assert_called_once_with('aaa-bbb') + self._cc.block_storage.delete_volume.assert_called_once_with('ccc-ddd') diff --git a/sushy_tools/tests/unit/emulator/resources/test_vmedia.py b/sushy_tools/tests/unit/emulator/resources/test_vmedia.py index 27478c6f..9c956f19 100644 --- a/sushy_tools/tests/unit/emulator/resources/test_vmedia.py +++ b/sushy_tools/tests/unit/emulator/resources/test_vmedia.py @@ -495,3 +495,100 @@ class StaticDriverTestCase(base.BaseTestCase): self.assertRaises(error.NotFound, self.test_driver.delete_certificate, self.UUID, 'Cd', 'Default') + + +class OpenstackDriverTestCase(base.BaseTestCase): + + UUID = 'ZZZ-YYY-XXX' + + def setUp(self): + super().setUp() + self.novadriver = mock.Mock() + with mock.patch('sushy_tools.emulator.memoize.PersistentDict', + return_value={}, autospec=True): + self.test_driver = vmedia.OpenstackDriver( + {}, mock.MagicMock(), self.novadriver) + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_insert_image(self, mock_get_device): + device_info = {} + mock_get_device.return_value = device_info + + self.novadriver.insert_image.return_value = ('aaa-bbb', 'red.iso') + + image_id = self.test_driver.insert_image( + self.UUID, 'Cd', 'http://fish.it/red.iso', inserted=True, + write_protected=False) + + self.novadriver.insert_image.assert_called_once_with( + self.UUID, 'http://fish.it/red.iso') + self.assertEqual('aaa-bbb', image_id) + + self.assertEqual('http://fish.it/red.iso', device_info['Image']) + self.assertEqual('red.iso', device_info['ImageName']) + self.assertTrue(device_info['Inserted']) + self.assertFalse(device_info['WriteProtected']) + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_insert_image_auth(self, mock_get_device): + device_info = {} + mock_get_device.return_value = device_info + + self.assertRaises( + error.NotSupportedError, self.test_driver.insert_image, + self.UUID, 'Cd', 'http://fish.it/red.iso', inserted=True, + write_protected=False, username='Admin', password='Secret') + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_insert_image_verify_ssl(self, mock_get_device): + device_info = {} + mock_get_device.return_value = device_info + + ssl_conf_key = 'SUSHY_EMULATOR_VMEDIA_VERIFY_SSL' + self.test_driver._config[ssl_conf_key] = True + self.assertRaises( + error.NotSupportedError, self.test_driver.insert_image, + self.UUID, 'Cd', 'https://fish.it/red.iso', inserted=True, + write_protected=False) + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_insert_image_fail(self, mock_get_device): + device_info = {} + mock_get_device.return_value = device_info + self.novadriver.insert_image.side_effect = error.FishyError('ouch') + + e = self.assertRaises( + error.FishyError, self.test_driver.insert_image, + self.UUID, 'Cd', 'http://fish.it/red.iso', inserted=True, + write_protected=False) + self.assertEqual('ouch', str(e)) + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_eject_image(self, mock_get_device): + + device_info = { + 'Image': 'http://fish.it/red.iso', + 'Inserted': True + } + mock_get_device.return_value = device_info + + self.test_driver.eject_image(self.UUID, 'Cd') + + self.assertFalse(device_info['Inserted']) + self.assertEqual('', device_info['Image']) + self.assertEqual('', device_info['ImageName']) + + @mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True) + def test_eject_image_error(self, mock_get_device): + device_info = { + 'Image': 'http://fish.it/red.iso', + 'Inserted': True + } + mock_get_device.return_value = device_info + self.novadriver.eject_image.side_effect = error.FishyError('ouch') + + e = self.assertRaises( + error.FishyError, self.test_driver.eject_image, + self.UUID, 'Cd') + self.assertEqual('ouch', str(e)) + self.assertTrue(device_info['Inserted'])