diff --git a/releasenotes/notes/add-delete-image-from-specific-store-api-84c0ecd50724f6de.yaml b/releasenotes/notes/add-delete-image-from-specific-store-api-84c0ecd50724f6de.yaml new file mode 100644 index 0000000000..a8a0b70201 --- /dev/null +++ b/releasenotes/notes/add-delete-image-from-specific-store-api-84c0ecd50724f6de.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add delete image from specific store API to image V2 client diff --git a/tempest/api/image/base.py b/tempest/api/image/base.py index 0544c319bb..89d5f913b7 100644 --- a/tempest/api/image/base.py +++ b/tempest/api/image/base.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import io import time from tempest import config @@ -95,6 +96,36 @@ class BaseV2ImageTest(BaseImageTest): namespace_name) return namespace + def create_and_stage_image(self, all_stores=False): + """Create Image & stage image file for glance-direct import method.""" + image_name = data_utils.rand_name('test-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('queued', image['status']) + + self.client.stage_image_file( + image['id'], + io.BytesIO(data_utils.random_bytes())) + # Check image status is 'uploading' + body = self.client.show_image(image['id']) + self.assertEqual(image['id'], body['id']) + self.assertEqual('uploading', body['status']) + + if all_stores: + stores_list = ','.join([store['id'] + for store in self.available_stores + if store.get('read-only') != 'true']) + else: + stores = [store['id'] for store in self.available_stores + if store.get('read-only') != 'true'] + stores_list = stores[::max(1, len(stores) - 1)] + + return body, stores_list + @classmethod def get_available_stores(cls): stores = [] diff --git a/tempest/api/image/v2/admin/test_images.py b/tempest/api/image/v2/admin/test_images.py index 27cdcd890d..2b1c4fb250 100644 --- a/tempest/api/image/v2/admin/test_images.py +++ b/tempest/api/image/v2/admin/test_images.py @@ -179,3 +179,59 @@ class ImageLocationsAdminTest(base.BaseV2ImageAdminTest): self.assertRaises(lib_exc.Forbidden, self.admin_client.update_image, image['id'], [ dict(remove='/locations/0')]) + + +class MultiStoresImagesTest(base.BaseV2ImageAdminTest, base.BaseV2ImageTest): + """Test importing and deleting image in multiple stores""" + @classmethod + def skip_checks(cls): + super(MultiStoresImagesTest, 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) + + @classmethod + def resource_setup(cls): + super(MultiStoresImagesTest, cls).resource_setup() + cls.available_import_methods = \ + cls.client.info_import()['import-methods']['value'] + if not cls.available_import_methods: + raise cls.skipException('Server does not support ' + 'any import method') + + # NOTE(pdeore): Skip if glance-direct import method and mutlistore + # are not enabled/configured, or only one store is configured in + # multiple stores setup. + cls.available_stores = cls.get_available_stores() + if ('glance-direct' not in cls.available_import_methods or + not len(cls.available_stores) > 1): + raise cls.skipException( + 'Either glance-direct import method not present in %s or ' + 'None or only one store is ' + 'configured %s' % (cls.available_import_methods, + cls.available_stores)) + + @decorators.idempotent_id('1ecec683-41d4-4470-a0df-54969ec74514') + def test_delete_image_from_specific_store(self): + """Test delete image from specific store""" + # Import image to available stores + image, stores = self.create_and_stage_image(all_stores=True) + self.client.image_import(image['id'], + method='glance-direct', + all_stores=True) + self.addCleanup(self.admin_client.delete_image, image['id']) + waiters.wait_for_image_imported_to_stores( + self.client, + image['id'], stores) + observed_image = self.client.show_image(image['id']) + + # Image will be deleted from first store + first_image_store_deleted = (observed_image['stores'].split(","))[0] + self.admin_client.delete_image_from_store( + observed_image['id'], first_image_store_deleted) + waiters.wait_for_image_deleted_from_store( + self.admin_client, + observed_image, + stores, + first_image_store_deleted) diff --git a/tempest/api/image/v2/test_images.py b/tempest/api/image/v2/test_images.py index be7424f158..e468e3262f 100644 --- a/tempest/api/image/v2/test_images.py +++ b/tempest/api/image/v2/test_images.py @@ -344,37 +344,6 @@ class MultiStoresImportImagesTest(base.BaseV2ImageTest): 'configured %s' % (cls.available_import_methods, cls.available_stores)) - def _create_and_stage_image(self, all_stores=False): - """Create Image & stage image file for glance-direct import method.""" - image_name = data_utils.rand_name( - prefix=CONF.resource_name_prefix, name='test-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('queued', image['status']) - - self.client.stage_image_file( - image['id'], - io.BytesIO(data_utils.random_bytes())) - # Check image status is 'uploading' - body = self.client.show_image(image['id']) - self.assertEqual(image['id'], body['id']) - self.assertEqual('uploading', body['status']) - - if all_stores: - stores_list = ','.join([store['id'] - for store in self.available_stores - if store.get('read-only') != 'true']) - else: - stores = [store['id'] for store in self.available_stores - if store.get('read-only') != 'true'] - stores_list = stores[::max(1, len(stores) - 1)] - - return body, stores_list - @decorators.idempotent_id('bf04ff00-3182-47cb-833a-f1c6767b47fd') def test_glance_direct_import_image_to_all_stores(self): """Test image is imported in all available stores @@ -382,7 +351,7 @@ class MultiStoresImportImagesTest(base.BaseV2ImageTest): Create image, import image to all available stores using glance-direct import method and verify that import succeeded. """ - image, stores = self._create_and_stage_image(all_stores=True) + image, stores = self.create_and_stage_image(all_stores=True) self.client.image_import( image['id'], method='glance-direct', all_stores=True) @@ -397,7 +366,7 @@ class MultiStoresImportImagesTest(base.BaseV2ImageTest): Create image, import image to specified store(s) using glance-direct import method and verify that import succeeded. """ - image, stores = self._create_and_stage_image() + image, stores = self.create_and_stage_image() self.client.image_import(image['id'], method='glance-direct', stores=stores) diff --git a/tempest/common/waiters.py b/tempest/common/waiters.py index d3be6fd881..ddc60472c7 100644 --- a/tempest/common/waiters.py +++ b/tempest/common/waiters.py @@ -311,6 +311,36 @@ def wait_for_image_copied_to_stores(client, image_id): raise lib_exc.TimeoutException(message) +def wait_for_image_deleted_from_store(client, image, available_stores, + image_store_deleted): + """Waits for an image to be deleted from specific store. + + API will not allow deletion of the last location for an image. + This return image if image deleted from store. + """ + + # Check if image have last store location + if len(available_stores) == 1: + exc_cls = lib_exc.OtherRestClientException + message = ('Delete from last store location not allowed' + % (image, image_store_deleted)) + raise exc_cls(message) + start = int(time.time()) + while int(time.time()) - start < client.build_timeout: + image = client.show_image(image['id']) + image_stores = image['stores'].split(",") + if image_store_deleted not in image_stores: + return + time.sleep(client.build_interval) + message = ('Failed to delete %s from requested store location: %s ' + 'within the required time: (%s s)' % + (image, image_store_deleted, client.build_timeout)) + caller = test_utils.find_test_caller() + if caller: + message = '(%s) %s' % (caller, message) + raise exc_cls(message) + + def wait_for_volume_resource_status(client, resource_id, status, server_id=None, servers_client=None): """Waits for a volume resource to reach a given status. diff --git a/tempest/lib/services/image/v2/images_client.py b/tempest/lib/services/image/v2/images_client.py index 8460b57c43..0608d473d3 100644 --- a/tempest/lib/services/image/v2/images_client.py +++ b/tempest/lib/services/image/v2/images_client.py @@ -292,3 +292,15 @@ class ImagesClient(rest_client.RestClient): resp, _ = self.delete(url) self.expected_success(204, resp.status) return rest_client.ResponseBody(resp) + + def delete_image_from_store(self, image_id, store_name): + """Delete image from store + + For a full list of available parameters, + please refer to the official API reference: + https://docs.openstack.org/api-ref/image/v2/#delete-image-from-store + """ + url = 'stores/%s/%s' % (store_name, image_id) + resp, _ = self.delete(url) + self.expected_success(204, resp.status) + return rest_client.ResponseBody(resp) diff --git a/tempest/tests/lib/services/image/v2/test_images_client.py b/tempest/tests/lib/services/image/v2/test_images_client.py index 27a50a95f5..01861a244c 100644 --- a/tempest/tests/lib/services/image/v2/test_images_client.py +++ b/tempest/tests/lib/services/image/v2/test_images_client.py @@ -146,6 +146,36 @@ class TestImagesClient(base.BaseServiceTest): ] } + FAKE_DELETE_IMAGE_FROM_STORE = { + "id": "e485aab9-0907-4973-921c-bb6da8a8fcf8", + "name": u"\u2740(*\xb4\u25e2`*)\u2740", + "status": "active", + "visibility": "public", + "size": 2254249, + "checksum": "2cec138d7dae2aa59038ef8c9aec2390", + "tags": [ + "fedora", + "beefy" + ], + "created_at": "2012-08-10T19:23:50Z", + "updated_at": "2012-08-12T11:11:33Z", + "self": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea", + "file": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927" + "dea/file", + "schema": "/v2/schemas/image", + "owner": None, + "min_ram": None, + "min_disk": None, + "disk_format": None, + "virtual_size": None, + "container_format": None, + "os_hash_algo": "sha512", + "os_hash_value": "ef7d1ed957ffafefb324d50ebc6685ed03d0e645d", + "os_hidden": False, + "protected": False, + "stores": ["store-1", "store-2"], + } + FAKE_TAG_NAME = "fake tag" def setUp(self): @@ -294,3 +324,12 @@ class TestImagesClient(base.BaseServiceTest): self.FAKE_SHOW_IMAGE_TASKS, True, image_id="e485aab9-0907-4973-921c-bb6da8a8fcf8") + + def test_delete_image_from_store(self): + self.check_service_client_function( + self.client.delete_image_from_store, + 'tempest.lib.common.rest_client.RestClient.delete', + {}, + image_id=self.FAKE_DELETE_IMAGE_FROM_STORE["id"], + store_name=self.FAKE_DELETE_IMAGE_FROM_STORE["stores"][0], + status=204)