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'])