Merge "Add delete image from specific store API"
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 0000000..a8a0b70
--- /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 0544c31..89d5f91 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 @@
                         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 27cdcd8..2b1c4fb 100644
--- a/tempest/api/image/v2/admin/test_images.py
+++ b/tempest/api/image/v2/admin/test_images.py
@@ -179,3 +179,59 @@
         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 be7424f..e468e32 100644
--- a/tempest/api/image/v2/test_images.py
+++ b/tempest/api/image/v2/test_images.py
@@ -344,37 +344,6 @@
                 '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 @@
         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 @@
         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 d3be6fd..ddc6047 100644
--- a/tempest/common/waiters.py
+++ b/tempest/common/waiters.py
@@ -311,6 +311,36 @@
     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 8460b57..0608d47 100644
--- a/tempest/lib/services/image/v2/images_client.py
+++ b/tempest/lib/services/image/v2/images_client.py
@@ -292,3 +292,15 @@
         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 27a50a9..01861a2 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 @@
         ]
     }
 
+    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 @@
             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)