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
This commit is contained in:
Steve Baker 2024-03-07 11:14:30 +13:00
parent 382a548e31
commit d5d4769482
9 changed files with 978 additions and 28 deletions

View File

@ -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',

View File

@ -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

View File

@ -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.

View File

@ -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

View File

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

View File

@ -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

View File

@ -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)

View File

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

View File

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