diff --git a/releasenotes/notes/add-location-api-5a57ab29dc6d6cd7.yaml b/releasenotes/notes/add-location-api-5a57ab29dc6d6cd7.yaml new file mode 100644 index 0000000000..f9166a2e27 --- /dev/null +++ b/releasenotes/notes/add-location-api-5a57ab29dc6d6cd7.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add new location API support to image V2 client diff --git a/tempest/api/image/v2/test_images.py b/tempest/api/image/v2/test_images.py index 9309c76de8..4375da5021 100644 --- a/tempest/api/image/v2/test_images.py +++ b/tempest/api/image/v2/test_images.py @@ -19,6 +19,7 @@ import random from oslo_log import log as logging from tempest.api.image import base +from tempest.common import image as image_utils from tempest.common import waiters from tempest import config from tempest.lib.common.utils import data_utils @@ -980,3 +981,87 @@ class ImageLocationsTest(base.BaseV2ImageTest): self.assertEqual(orig_image['os_hash_value'], image['os_hash_value']) self.assertEqual(orig_image['os_hash_algo'], image['os_hash_algo']) self.assertNotIn('validation_data', image['locations'][0]) + + +class HashCalculationRemoteDeletionTest(base.BaseV2ImageTest): + """Test calculation of image hash with new location API when the image is + deleted from a remote Glance service. + """ + @classmethod + def resource_setup(cls): + super(HashCalculationRemoteDeletionTest, + cls).resource_setup() + if not cls.versions_client.has_version('2.17'): + # API is not new enough to support add location API + skip_msg = ( + '%s skipped as Glance does not support v2.17') + raise cls.skipException(skip_msg) + + @classmethod + def skip_checks(cls): + super(HashCalculationRemoteDeletionTest, + cls).skip_checks() + if not CONF.image_feature_enabled.do_secure_hash: + skip_msg = ( + "%s skipped as do_secure_hash is disabled" % + cls.__name__) + raise cls.skipException(skip_msg) + + if not CONF.image_feature_enabled.http_store_enabled: + skip_msg = ( + "%s skipped as http store is disabled" % + cls.__name__) + raise cls.skipException(skip_msg) + + @decorators.idempotent_id('123e4567-e89b-12d3-a456-426614174000') + def test_hash_calculation_cancelled(self): + """Test that image hash calculation is cancelled when the image + is deleted from a remote Glance service. + + This test creates an image using new location API, verifies that + the hash calculation is initiated, and then deletes the image from a + remote Glance service, and verifies that the hash calculation process + is properly cancelled and image deleted successfully. + """ + + # Create an image with a location + 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') + self.assertEqual(image_name, image['name']) + self.assertEqual('queued', image['status']) + + # Start http server at random port to simulate the image location + # and to provide random data for the image with slow transfer + server = image_utils.RandomDataServer() + server.start() + self.addCleanup(server.stop) + + # Add a location to the image + location = 'http://localhost:%d' % server.port + self.client.add_image_location(image['id'], location) + waiters.wait_for_image_status(self.client, image['id'], 'active') + + # Verify that the hash calculation is initiated + image_info = self.client.show_image(image['id']) + self.assertEqual(CONF.image.hashing_algorithm, + image_info['os_hash_algo']) + self.assertEqual('active', image_info['status']) + + if CONF.image.alternate_image_endpoint: + # If alternate image endpoint is configured, we will delete the + # image from the alternate worker + self.os_primary.image_client_remote.delete_image(image['id']) + else: + # delete image from backend + self.client.delete_image(image['id']) + + # If image is deleted successfully, the hash calculation is cancelled + self.client.wait_for_resource_deletion(image['id']) + + # Stop the server to release the port + server.stop() diff --git a/tempest/common/image.py b/tempest/common/image.py index 3618f7e131..b8f76fb6bc 100644 --- a/tempest/common/image.py +++ b/tempest/common/image.py @@ -14,6 +14,10 @@ # under the License. import copy +from http import server +import random +import threading +import time def get_image_meta_from_headers(resp): @@ -63,3 +67,57 @@ def image_meta_to_headers(**metadata): headers['x-image-meta-%s' % key] = str(value) return headers + + +class RandomDataHandler(server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.end_headers() + + start_time = time.time() + chunk_size = 64 * 1024 # 64 KiB per chunk + while time.time() - start_time < 60: + data = bytes(random.getrandbits(8) for _ in range(chunk_size)) + try: + self.wfile.write(data) + self.wfile.flush() + # simulate slow transfer + time.sleep(0.2) + except BrokenPipeError: + # Client disconnected; stop sending data + break + + def do_HEAD(self): + # same size as in do_GET (19,660,800 bytes (about 18.75 MiB) + size = 300 * 65536 + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.send_header('Content-Length', str(size)) + self.end_headers() + + +class RandomDataServer(object): + def __init__(self, handler_class=RandomDataHandler): + self.handler_class = handler_class + self.server = None + self.thread = None + self.port = None + + def start(self): + # Bind to port 0 for an unused port + self.server = server.HTTPServer(('localhost', 0), self.handler_class) + self.port = self.server.server_address[1] + + # Run server in background thread + self.thread = threading.Thread(target=self.server.serve_forever) + self.thread.daemon = True + self.thread.start() + + def stop(self): + if self.server: + self.server.shutdown() + self.server.server_close() + self.thread.join() + self.server = None + self.thread = None diff --git a/tempest/config.py b/tempest/config.py index 9c288ffdf8..bf906b4777 100644 --- a/tempest/config.py +++ b/tempest/config.py @@ -688,6 +688,11 @@ ImageGroup = [ 'vdi', 'iso', 'vhdx'], help="A list of image's disk formats " "users can specify."), + cfg.StrOpt('hashing_algorithm', + default='sha512', + help=('Hashing algorithm used by glance to calculate image ' + 'hashes. This configuration value should be same as ' + 'glance-api.conf: hashing_algorithm config option.')), cfg.StrOpt('images_manifest_file', default=None, help="A path to a manifest.yml generated using the " @@ -732,6 +737,17 @@ ImageFeaturesGroup = [ help=('Indicates that image format is enforced by glance, ' 'such that we should not expect to be able to upload ' 'bad images for testing other services.')), + cfg.BoolOpt('do_secure_hash', + default=True, + help=('Is do_secure_hash enabled in glance. ' + 'This configuration value should be same as ' + 'glance-api.conf: do_secure_hash config option.')), + cfg.BoolOpt('http_store_enabled', + default=False, + help=('Is http store is enabled in glance. ' + 'http store needs to be mentioned either in ' + 'glance-api.conf: stores or in enabled_backends ' + 'configuration option.')), ] network_group = cfg.OptGroup(name='network', diff --git a/tempest/lib/services/image/v2/images_client.py b/tempest/lib/services/image/v2/images_client.py index a6a16234d6..c491d9b077 100644 --- a/tempest/lib/services/image/v2/images_client.py +++ b/tempest/lib/services/image/v2/images_client.py @@ -304,3 +304,13 @@ class ImagesClient(rest_client.RestClient): resp, _ = self.delete(url) self.expected_success(204, resp.status) return rest_client.ResponseBody(resp) + + def add_image_location(self, image_id, url, validation_data=None): + """Add location for specific Image.""" + if not validation_data: + validation_data = {} + data = json.dumps({'url': url, 'validation_data': validation_data}) + resp, _ = self.post('images/%s/locations' % (image_id), + data) + self.expected_success(202, resp.status) + return rest_client.ResponseBody(resp)