Tempest tests for glance import workflow
This adds an initial test for glance image interoperable import that uses the glance-direct method. It will skip if the server does not support glance-direct. Adding feature flag to enable the import tests as devstack on stable branches cannot support image import feature. Change-Id: I09e7fb4e7758edd5256ae70ceeea6f143466c3e3
This commit is contained in:
parent
50ec7d74c3
commit
7cff130795
@ -180,6 +180,7 @@
|
|||||||
USE_PYTHON3: true
|
USE_PYTHON3: true
|
||||||
FORCE_CONFIG_DRIVE: true
|
FORCE_CONFIG_DRIVE: true
|
||||||
ENABLE_VOLUME_MULTIATTACH: true
|
ENABLE_VOLUME_MULTIATTACH: true
|
||||||
|
GLANCE_USE_IMPORT_WORKFLOW: True
|
||||||
devstack_services:
|
devstack_services:
|
||||||
s-account: false
|
s-account: false
|
||||||
s-container: false
|
s-container: false
|
||||||
@ -270,6 +271,7 @@
|
|||||||
USE_PYTHON3: true
|
USE_PYTHON3: true
|
||||||
FORCE_CONFIG_DRIVE: true
|
FORCE_CONFIG_DRIVE: true
|
||||||
ENABLE_VOLUME_MULTIATTACH: true
|
ENABLE_VOLUME_MULTIATTACH: true
|
||||||
|
GLANCE_USE_IMPORT_WORKFLOW: True
|
||||||
|
|
||||||
- job:
|
- job:
|
||||||
name: tempest-integrated-object-storage
|
name: tempest-integrated-object-storage
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Add glance image import APIs function to v2
|
||||||
|
images_client library.
|
||||||
|
|
||||||
|
* stage_image_file
|
||||||
|
* info_import
|
||||||
|
* info_stores
|
||||||
|
* image_import
|
||||||
|
other:
|
||||||
|
- |
|
||||||
|
New configuration options
|
||||||
|
``CONF.glance.image_feature_enabled.image_import`` has been introduced
|
||||||
|
to enable the image import tests. If your glance deployement support
|
||||||
|
image import functionality then you can enable the image import tests
|
||||||
|
via this flag. Default value of this new config option is false.
|
@ -29,6 +29,64 @@ CONF = config.CONF
|
|||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportImagesTest(base.BaseV2ImageTest):
|
||||||
|
"""Here we test the import operations for image"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def skip_checks(cls):
|
||||||
|
super(ImportImagesTest, cls).skip_checks()
|
||||||
|
if not CONF.image_feature_enabled.import_image:
|
||||||
|
skip_msg = (
|
||||||
|
"%s skipped as image import is not available" % cls.__name__)
|
||||||
|
raise cls.skipException(skip_msg)
|
||||||
|
|
||||||
|
@decorators.idempotent_id('32ca0c20-e16f-44ac-8590-07869c9b4cc2')
|
||||||
|
def test_image_import(self):
|
||||||
|
"""Here we test these functionalities
|
||||||
|
|
||||||
|
Create image, stage image data, import image and verify
|
||||||
|
that import succeeded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
body = self.client.info_import()
|
||||||
|
if 'glance-direct' not in body['import-methods']['value']:
|
||||||
|
raise self.skipException('Server does not support '
|
||||||
|
'glance-direct import method')
|
||||||
|
|
||||||
|
# Create image
|
||||||
|
uuid = '00000000-1111-2222-3333-444455556666'
|
||||||
|
image_name = data_utils.rand_name('image')
|
||||||
|
container_format = CONF.image.container_formats[0]
|
||||||
|
disk_format = CONF.image.disk_formats[0]
|
||||||
|
image = self.create_image(name=image_name,
|
||||||
|
container_format=container_format,
|
||||||
|
disk_format=disk_format,
|
||||||
|
visibility='private',
|
||||||
|
ramdisk_id=uuid)
|
||||||
|
self.assertIn('name', image)
|
||||||
|
self.assertEqual(image_name, image['name'])
|
||||||
|
self.assertIn('visibility', image)
|
||||||
|
self.assertEqual('private', image['visibility'])
|
||||||
|
self.assertIn('status', image)
|
||||||
|
self.assertEqual('queued', image['status'])
|
||||||
|
|
||||||
|
# Stage image data
|
||||||
|
file_content = data_utils.random_bytes()
|
||||||
|
image_file = six.BytesIO(file_content)
|
||||||
|
self.client.stage_image_file(image['id'], image_file)
|
||||||
|
|
||||||
|
# Now try to get image details
|
||||||
|
body = self.client.show_image(image['id'])
|
||||||
|
self.assertEqual(image['id'], body['id'])
|
||||||
|
self.assertEqual(image_name, body['name'])
|
||||||
|
self.assertEqual(uuid, body['ramdisk_id'])
|
||||||
|
self.assertEqual('uploading', body['status'])
|
||||||
|
|
||||||
|
# import image from staging to backend
|
||||||
|
self.client.image_import(image['id'])
|
||||||
|
self.client.wait_for_resource_activation(image['id'])
|
||||||
|
|
||||||
|
|
||||||
class BasicOperationsImagesTest(base.BaseV2ImageTest):
|
class BasicOperationsImagesTest(base.BaseV2ImageTest):
|
||||||
"""Here we test the basic operations of images"""
|
"""Here we test the basic operations of images"""
|
||||||
|
|
||||||
|
@ -658,6 +658,12 @@ ImageFeaturesGroup = [
|
|||||||
'are current one. In future, Tempest will '
|
'are current one. In future, Tempest will '
|
||||||
'test v2 APIs only so this config option '
|
'test v2 APIs only so this config option '
|
||||||
'will be removed.'),
|
'will be removed.'),
|
||||||
|
# Image import feature is setup in devstack victoria onwards.
|
||||||
|
# Once all stable branches setup the same via glance standalone
|
||||||
|
# mode or with uwsgi, we can remove this config option.
|
||||||
|
cfg.BoolOpt('import_image',
|
||||||
|
default=False,
|
||||||
|
help="Is image import feature enabled"),
|
||||||
]
|
]
|
||||||
|
|
||||||
network_group = cfg.OptGroup(name='network',
|
network_group = cfg.OptGroup(name='network',
|
||||||
|
@ -914,12 +914,44 @@ class RestClient(object):
|
|||||||
raise exceptions.TimeoutException(message)
|
raise exceptions.TimeoutException(message)
|
||||||
time.sleep(self.build_interval)
|
time.sleep(self.build_interval)
|
||||||
|
|
||||||
|
def wait_for_resource_activation(self, id):
|
||||||
|
"""Waits for a resource to become active
|
||||||
|
|
||||||
|
This method will loop over is_resource_active until either
|
||||||
|
is_resource_active returns True or the build timeout is reached. This
|
||||||
|
depends on is_resource_active being implemented
|
||||||
|
|
||||||
|
:param str id: The id of the resource to check
|
||||||
|
:raises TimeoutException: If the build_timeout has elapsed and the
|
||||||
|
resource still hasn't been active
|
||||||
|
"""
|
||||||
|
start_time = int(time.time())
|
||||||
|
while True:
|
||||||
|
if self.is_resource_active(id):
|
||||||
|
return
|
||||||
|
if int(time.time()) - start_time >= self.build_timeout:
|
||||||
|
message = ('Failed to reach active state %(resource_type)s '
|
||||||
|
'%(id)s within the required time (%(timeout)s s).' %
|
||||||
|
{'resource_type': self.resource_type, 'id': id,
|
||||||
|
'timeout': self.build_timeout})
|
||||||
|
caller = test_utils.find_test_caller()
|
||||||
|
if caller:
|
||||||
|
message = '(%s) %s' % (caller, message)
|
||||||
|
raise exceptions.TimeoutException(message)
|
||||||
|
time.sleep(self.build_interval)
|
||||||
|
|
||||||
def is_resource_deleted(self, id):
|
def is_resource_deleted(self, id):
|
||||||
"""Subclasses override with specific deletion detection."""
|
"""Subclasses override with specific deletion detection."""
|
||||||
message = ('"%s" does not implement is_resource_deleted'
|
message = ('"%s" does not implement is_resource_deleted'
|
||||||
% self.__class__.__name__)
|
% self.__class__.__name__)
|
||||||
raise NotImplementedError(message)
|
raise NotImplementedError(message)
|
||||||
|
|
||||||
|
def is_resource_active(self, id):
|
||||||
|
"""Subclasses override with specific active detection."""
|
||||||
|
message = ('"%s" does not implement is_resource_active'
|
||||||
|
% self.__class__.__name__)
|
||||||
|
raise NotImplementedError(message)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resource_type(self):
|
def resource_type(self):
|
||||||
"""Returns the primary type of resource this client works with."""
|
"""Returns the primary type of resource this client works with."""
|
||||||
|
@ -128,6 +128,15 @@ class ImagesClient(rest_client.RestClient):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_resource_active(self, id):
|
||||||
|
try:
|
||||||
|
image = self.show_image(id)
|
||||||
|
if image['status'] != 'active':
|
||||||
|
return False
|
||||||
|
except lib_exc.NotFound:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resource_type(self):
|
def resource_type(self):
|
||||||
"""Returns the primary type of resource this client works with."""
|
"""Returns the primary type of resource this client works with."""
|
||||||
@ -152,6 +161,80 @@ class ImagesClient(rest_client.RestClient):
|
|||||||
self.expected_success(204, resp.status)
|
self.expected_success(204, resp.status)
|
||||||
return rest_client.ResponseBody(resp, body)
|
return rest_client.ResponseBody(resp, body)
|
||||||
|
|
||||||
|
def stage_image_file(self, image_id, data):
|
||||||
|
"""Upload binary image data to staging area.
|
||||||
|
|
||||||
|
For a full list of available parameters, please refer to the official
|
||||||
|
API reference (stage API:
|
||||||
|
https://docs.openstack.org/api-ref/image/v2/#interoperable-image-import
|
||||||
|
"""
|
||||||
|
url = 'images/%s/stage' % image_id
|
||||||
|
|
||||||
|
# We are going to do chunked transfer, so split the input data
|
||||||
|
# info fixed-sized chunks.
|
||||||
|
headers = {'Content-Type': 'application/octet-stream'}
|
||||||
|
data = iter(functools.partial(data.read, CHUNKSIZE), b'')
|
||||||
|
|
||||||
|
resp, body = self.request('PUT', url, headers=headers,
|
||||||
|
body=data, chunked=True)
|
||||||
|
self.expected_success(204, resp.status)
|
||||||
|
return rest_client.ResponseBody(resp, body)
|
||||||
|
|
||||||
|
def info_import(self):
|
||||||
|
"""Return information about server-supported import methods."""
|
||||||
|
url = 'info/import'
|
||||||
|
resp, body = self.get(url)
|
||||||
|
|
||||||
|
self.expected_success(200, resp.status)
|
||||||
|
body = json.loads(body)
|
||||||
|
return rest_client.ResponseBody(resp, body)
|
||||||
|
|
||||||
|
def info_stores(self):
|
||||||
|
"""Return information about server-supported stores."""
|
||||||
|
url = 'info/stores'
|
||||||
|
resp, body = self.get(url)
|
||||||
|
body = json.loads(body)
|
||||||
|
return rest_client.ResponseBody(resp, body)
|
||||||
|
|
||||||
|
def image_import(self, image_id, method='glance-direct',
|
||||||
|
all_stores_must_succeed=None, all_stores=True,
|
||||||
|
stores=None):
|
||||||
|
"""Import data from staging area to glance store.
|
||||||
|
|
||||||
|
For a full list of available parameters, please refer to the official
|
||||||
|
API reference (stage API:
|
||||||
|
https://docs.openstack.org/api-ref/image/v2/#interoperable-image-import
|
||||||
|
|
||||||
|
:param method: The import method (i.e. glance-direct) to use
|
||||||
|
:param all_stores_must_succeed: Boolean indicating if all store imports
|
||||||
|
must succeed for the import to be
|
||||||
|
considered successful. Must be None if
|
||||||
|
server does not support multistore.
|
||||||
|
:param all_stores: Boolean indicating if image should be imported to
|
||||||
|
all available stores (incompatible with stores)
|
||||||
|
:param stores: A list of destination store names for the import. Must
|
||||||
|
be None if server does not support multistore.
|
||||||
|
"""
|
||||||
|
url = 'images/%s/import' % image_id
|
||||||
|
data = {
|
||||||
|
"method": {
|
||||||
|
"name": method
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if stores is not None:
|
||||||
|
data["stores"] = stores
|
||||||
|
else:
|
||||||
|
data["all_stores"] = all_stores
|
||||||
|
|
||||||
|
if all_stores_must_succeed is not None:
|
||||||
|
data['all_stores_must_succeed'] = all_stores_must_succeed
|
||||||
|
data = json.dumps(data)
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
resp, _ = self.post(url, data, headers=headers)
|
||||||
|
|
||||||
|
self.expected_success(202, resp.status)
|
||||||
|
return rest_client.ResponseBody(resp)
|
||||||
|
|
||||||
def show_image_file(self, image_id):
|
def show_image_file(self, image_id):
|
||||||
"""Download binary image data.
|
"""Download binary image data.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user