Merge "Use 'tempest run --slowest' in integrated-compute (again)"
diff --git a/releasenotes/notes/add-enable-volume-image-dep-tests-option-150b929d18da233f.yaml b/releasenotes/notes/add-enable-volume-image-dep-tests-option-150b929d18da233f.yaml
new file mode 100644
index 0000000..e78201e
--- /dev/null
+++ b/releasenotes/notes/add-enable-volume-image-dep-tests-option-150b929d18da233f.yaml
@@ -0,0 +1,6 @@
+---
+features:
+  - |
+    Add new config option 'enable_volume_image_dep_tests' in section
+    [volume-feature-enabled] which should be used in
+    image<->volume<->snapshot dependency tests.
diff --git a/releasenotes/notes/add-placement-resource-provider-traits-api-calls-9f4b0455afec9afb.yaml b/releasenotes/notes/add-placement-resource-provider-traits-api-calls-9f4b0455afec9afb.yaml
new file mode 100644
index 0000000..1d1811c
--- /dev/null
+++ b/releasenotes/notes/add-placement-resource-provider-traits-api-calls-9f4b0455afec9afb.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - |
+    Adds API calls for traits in ResourceProvidersClient.
diff --git a/releasenotes/notes/add-placement-traits-api-calls-087061f5455f0b12.yaml b/releasenotes/notes/add-placement-traits-api-calls-087061f5455f0b12.yaml
new file mode 100644
index 0000000..77d0b38
--- /dev/null
+++ b/releasenotes/notes/add-placement-traits-api-calls-087061f5455f0b12.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - |
+    Adds API calls for traits in PlacementClient.
diff --git a/tempest/api/compute/servers/test_delete_server.py b/tempest/api/compute/servers/test_delete_server.py
index ee25a22..596d2bd 100644
--- a/tempest/api/compute/servers/test_delete_server.py
+++ b/tempest/api/compute/servers/test_delete_server.py
@@ -99,11 +99,14 @@
     def test_delete_server_while_in_verify_resize_state(self):
         """Test deleting a server while it's VM state is VERIFY_RESIZE"""
         server = self.create_test_server(wait_until='ACTIVE')
-        self.client.resize_server(server['id'], self.flavor_ref_alt)
-        waiters.wait_for_server_status(self.client, server['id'],
-                                       'VERIFY_RESIZE')
-        self.client.delete_server(server['id'])
-        waiters.wait_for_server_termination(self.client, server['id'])
+        body = self.client.resize_server(server['id'], self.flavor_ref_alt)
+        request_id = body.response['x-openstack-request-id']
+        waiters.wait_for_server_status(
+            self.client, server['id'], 'VERIFY_RESIZE', request_id=request_id)
+        body = self.client.delete_server(server['id'])
+        request_id = body.response['x-openstack-request-id']
+        waiters.wait_for_server_termination(
+            self.client, server['id'], request_id=request_id)
 
     @decorators.idempotent_id('d0f3f0d6-d9b6-4a32-8da4-23015dcab23c')
     @utils.services('volume')
diff --git a/tempest/api/image/v2/test_images_dependency.py b/tempest/api/image/v2/test_images_dependency.py
new file mode 100644
index 0000000..326045b
--- /dev/null
+++ b/tempest/api/image/v2/test_images_dependency.py
@@ -0,0 +1,103 @@
+# Copyright 2024 OpenStack Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    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 io
+
+from oslo_log import log as logging
+
+from tempest.api.compute import base as compute_base
+from tempest.api.image import base as image_base
+from tempest.common import utils
+from tempest.common import waiters
+from tempest import config
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+from tempest.scenario import manager
+
+CONF = config.CONF
+LOG = logging.getLogger(__name__)
+
+
+class ImageDependencyTests(image_base.BaseV2ImageTest,
+                           compute_base.BaseV2ComputeTest,
+                           manager.ScenarioTest):
+    """Test image, instance, and snapshot dependency.
+
+       The tests create image and remove the base image that other snapshots
+       were depend on.In OpenStack, images and snapshots should be separate,
+       but in some configurations like Glance with Ceph storage,
+       there were cases where images couldn't be removed.
+       This was fixed in glance store for RBD backend.
+
+       * Dependency scenarios:
+           - image > instance -> snapshot dependency
+
+       NOTE: volume -> image dependencies tests are in cinder-tempest-plugin
+    """
+
+    @classmethod
+    def skip_checks(cls):
+        super(ImageDependencyTests, cls).skip_checks()
+        if not CONF.volume_feature_enabled.enable_volume_image_dep_tests:
+            skip_msg = (
+                "%s Volume/image dependency tests "
+                "not enabled" % (cls.__name__))
+            raise cls.skipException(skip_msg)
+
+    def _create_instance_snapshot(self):
+        """Create instance from image and then snapshot the instance."""
+        # Create image and store data to image
+        image_name = data_utils.rand_name(
+            prefix=CONF.resource_name_prefix,
+            name='image-dependency-test')
+        image = self.create_image(name=image_name,
+                                  container_format='bare',
+                                  disk_format='raw',
+                                  visibility='private')
+        file_content = data_utils.random_bytes()
+        image_file = io.BytesIO(file_content)
+        self.client.store_image_file(image['id'], image_file)
+        waiters.wait_for_image_status(
+            self.client, image['id'], 'active')
+        # Create instance
+        instance = self.create_test_server(
+            name='instance-depend-image',
+            image_id=image['id'],
+            wait_until='ACTIVE')
+        LOG.info("Instance from image is created %s", instance)
+        instance_observed = \
+            self.servers_client.show_server(instance['id'])['server']
+        # Create instance snapshot
+        snapshot_instance = self.create_server_snapshot(
+            server=instance_observed)
+        LOG.info("Instance snapshot is created %s", snapshot_instance)
+        return image['id'], snapshot_instance['id']
+
+    @decorators.idempotent_id('d19b0731-e98e-4103-8b0e-02f651b8f586')
+    @utils.services('compute')
+    def test_nova_image_snapshot_dependency(self):
+        """Test with image > instance > snapshot dependency.
+
+        Create instance snapshot and check if we able to delete base
+        image
+
+        """
+        base_image_id, snapshot_image_id = self._create_instance_snapshot()
+        self.client.delete_image(base_image_id)
+        self.client.wait_for_resource_deletion(base_image_id)
+        images_list = self.client.list_images()['images']
+        fetched_images_id = [img['id'] for img in images_list]
+        self.assertNotIn(base_image_id, fetched_images_id)
+        self.assertIn(snapshot_image_id, fetched_images_id)
diff --git a/tempest/common/utils/linux/remote_client.py b/tempest/common/utils/linux/remote_client.py
index 0d93430..dd18190 100644
--- a/tempest/common/utils/linux/remote_client.py
+++ b/tempest/common/utils/linux/remote_client.py
@@ -182,6 +182,9 @@
     def umount(self, mount_path='/mnt'):
         self.exec_command('sudo umount %s' % mount_path)
 
+    def mkdir(self, dir_path):
+        self.exec_command('sudo mkdir -p %s' % dir_path)
+
     def make_fs(self, dev_name, fs='ext4'):
         cmd_mkfs = 'sudo mkfs -t %s /dev/%s' % (fs, dev_name)
         try:
diff --git a/tempest/common/waiters.py b/tempest/common/waiters.py
index d65b491..e249f35 100644
--- a/tempest/common/waiters.py
+++ b/tempest/common/waiters.py
@@ -103,7 +103,8 @@
         old_task_state = task_state
 
 
-def wait_for_server_termination(client, server_id, ignore_error=False):
+def wait_for_server_termination(client, server_id, ignore_error=False,
+                                request_id=None):
     """Waits for server to reach termination."""
     try:
         body = client.show_server(server_id)['server']
@@ -126,9 +127,13 @@
                      '/'.join((server_status, str(task_state))),
                      time.time() - start_time)
         if server_status == 'ERROR' and not ignore_error:
-            raise lib_exc.DeleteErrorException(
-                "Server %s failed to delete and is in ERROR status" %
-                server_id)
+            details = ("Server %s failed to delete and is in ERROR status." %
+                       server_id)
+            if 'fault' in body:
+                details += ' Fault: %s.' % body['fault']
+            if request_id:
+                details += ' Server delete request ID: %s.' % request_id
+            raise lib_exc.DeleteErrorException(details, server_id=server_id)
 
         if server_status == 'SOFT_DELETED':
             # Soft-deleted instances need to be forcibly deleted to
diff --git a/tempest/config.py b/tempest/config.py
index fc50db5..4b5330a 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -1071,7 +1071,10 @@
                default=None,
                help='Volume types used for data volumes. Multiple volume '
                     'types can be assigned.'),
-
+    cfg.BoolOpt('enable_volume_image_dep_tests',
+                default=True,
+                help='Run tests for dependencies between images, volumes'
+                'and instance snapshots')
 ]
 
 
@@ -1176,7 +1179,7 @@
                default='icmp',
                choices=('icmp', 'tcp', 'udp'),
                help='The protocol used in security groups tests to check '
-                    'connectivity.'),
+                    'connectivity.')
 ]
 
 
diff --git a/tempest/lib/services/placement/placement_client.py b/tempest/lib/services/placement/placement_client.py
index 216ac08..f272cbf 100644
--- a/tempest/lib/services/placement/placement_client.py
+++ b/tempest/lib/services/placement/placement_client.py
@@ -49,3 +49,39 @@
         self.expected_success(200, resp.status)
         body = json.loads(body)
         return rest_client.ResponseBody(resp, body)
+
+    def list_traits(self, **params):
+        """API ref https://docs.openstack.org/api-ref/placement/#traits
+        """
+        url = "/traits"
+        if params:
+            url += '?%s' % urllib.urlencode(params)
+        resp, body = self.get(url)
+        self.expected_success(200, resp.status)
+        body = json.loads(body)
+        return rest_client.ResponseBody(resp, body)
+
+    def show_trait(self, name, **params):
+        url = "/traits"
+        if params:
+            url += '?%s' % urllib.urlencode(params)
+            resp, body = self.get(url)
+            body = json.loads(body)
+            self.expected_success(200, resp.status)
+            return rest_client.ResponseBody(resp, body)
+        url = f"{url}/{name}"
+        resp, _ = self.get(url)
+        self.expected_success(204, resp.status)
+        return resp.status
+
+    def create_trait(self, name, **params):
+        url = f"/traits/{name}"
+        json_body = json.dumps(params)
+        resp, _ = self.put(url, body=json_body)
+        return resp.status
+
+    def delete_trait(self, name):
+        url = f"/traits/{name}"
+        resp, _ = self.delete(url)
+        self.expected_success(204, resp.status)
+        return resp.status
diff --git a/tempest/lib/services/placement/resource_providers_client.py b/tempest/lib/services/placement/resource_providers_client.py
index 3214053..a336500 100644
--- a/tempest/lib/services/placement/resource_providers_client.py
+++ b/tempest/lib/services/placement/resource_providers_client.py
@@ -121,3 +121,29 @@
         resp, body = self.delete(url)
         self.expected_success(204, resp.status)
         return rest_client.ResponseBody(resp, body)
+
+    def list_resource_provider_traits(self, rp_uuid, **kwargs):
+        """https://docs.openstack.org/api-ref/placement/#resource-provider-traits
+        """
+        url = f"/resource_providers/{rp_uuid}/traits"
+        if kwargs:
+            url += '?%s' % urllib.urlencode(kwargs)
+        resp, body = self.get(url)
+        self.expected_success(200, resp.status)
+        body = json.loads(body)
+        return rest_client.ResponseBody(resp, body)
+
+    def update_resource_provider_traits(self, rp_uuid, **kwargs):
+        url = f"/resource_providers/{rp_uuid}/traits"
+        data = json.dumps(kwargs)
+        resp, body = self.put(url, data)
+        self.expected_success(200, resp.status)
+        body = json.loads(body)
+        return rest_client.ResponseBody(resp, body)
+
+    def delete_resource_provider_traits(self, rp_uuid):
+        url = f"/resource_providers/{rp_uuid}/traits"
+        resp, body = self.delete(url)
+        self.expected_success(204, resp.status)
+        body = json.loads(body)
+        return rest_client.ResponseBody(resp, body)
diff --git a/tempest/scenario/manager.py b/tempest/scenario/manager.py
index d51e7e5..714a7c7 100644
--- a/tempest/scenario/manager.py
+++ b/tempest/scenario/manager.py
@@ -1229,16 +1229,18 @@
         # dev_name to mount_path.
         target_dir = '/tmp'
         if dev_name is not None:
+            mount_path = os.path.join(mount_path, dev_name)
             ssh_client.make_fs(dev_name, fs=fs)
-            ssh_client.exec_command('sudo mount /dev/%s %s' % (dev_name,
-                                                               mount_path))
+            ssh_client.mkdir(mount_path)
+            ssh_client.mount(dev_name, mount_path)
             target_dir = mount_path
+
         cmd_timestamp = 'sudo sh -c "date > %s/timestamp; sync"' % target_dir
         ssh_client.exec_command(cmd_timestamp)
         timestamp = ssh_client.exec_command('sudo cat %s/timestamp'
                                             % target_dir)
         if dev_name is not None:
-            ssh_client.exec_command('sudo umount %s' % mount_path)
+            ssh_client.umount(mount_path)
         return timestamp
 
     def get_timestamp(self, ip_address, dev_name=None, mount_path='/mnt',
@@ -1266,12 +1268,14 @@
         # dev_name to mount_path.
         target_dir = '/tmp'
         if dev_name is not None:
+            mount_path = os.path.join(mount_path, dev_name)
+            ssh_client.mkdir(mount_path)
             ssh_client.mount(dev_name, mount_path)
             target_dir = mount_path
         timestamp = ssh_client.exec_command('sudo cat %s/timestamp'
                                             % target_dir)
         if dev_name is not None:
-            ssh_client.exec_command('sudo umount %s' % mount_path)
+            ssh_client.umount(mount_path)
         return timestamp
 
     def get_server_ip(self, server, **kwargs):
diff --git a/tempest/scenario/test_instances_with_cinder_volumes.py b/tempest/scenario/test_instances_with_cinder_volumes.py
index 5f33b49..b9ac2c8 100644
--- a/tempest/scenario/test_instances_with_cinder_volumes.py
+++ b/tempest/scenario/test_instances_with_cinder_volumes.py
@@ -181,31 +181,24 @@
                 server=server
             )
 
+            server_name = server['name'].split('-')[-1]
+
             # run write test on all volumes
             for volume in attached_volumes:
 
-                waiters.wait_for_volume_resource_status(
-                    self.volumes_client, volume['id'], 'in-use')
-
-                # get the mount path
-                mount_path = f"/mnt/{volume['attachments'][0]['device'][5:]}"
-
-                # create file for mounting on server
-                self.create_file(ssh_ip, mount_path,
-                                 private_key=keypair['private_key'],
-                                 server=server)
-
                 # dev name volume['attachments'][0]['device'][5:] is like
                 # /dev/vdb, we need to remove /dev/ -> first 5 chars
+                dev_name = volume['attachments'][0]['device'][5:]
+
+                mount_path = f"/mnt/{server_name}"
+
                 timestamp_before = self.create_timestamp(
                     ssh_ip, private_key=keypair['private_key'], server=server,
-                    dev_name=volume['attachments'][0]['device'][5:],
-                    mount_path=mount_path
+                    dev_name=dev_name, mount_path=mount_path,
                 )
                 timestamp_after = self.get_timestamp(
                     ssh_ip, private_key=keypair['private_key'], server=server,
-                    dev_name=volume['attachments'][0]['device'][5:],
-                    mount_path=mount_path
+                    dev_name=dev_name, mount_path=mount_path,
                 )
                 self.assertEqual(timestamp_before, timestamp_after)
 
diff --git a/tempest/tests/lib/services/placement/test_placement_client.py b/tempest/tests/lib/services/placement/test_placement_client.py
index 1396a85..bb57bb0 100644
--- a/tempest/tests/lib/services/placement/test_placement_client.py
+++ b/tempest/tests/lib/services/placement/test_placement_client.py
@@ -87,3 +87,77 @@
 
     def test_list_allocations_with_bytes_body(self):
         self._test_list_allocations(bytes_body=True)
+
+    FAKE_ALL_TRAITS = {
+        "traits": [
+            "CUSTOM_HW_FPGA_CLASS1",
+            "CUSTOM_HW_FPGA_CLASS2",
+            "CUSTOM_HW_FPGA_CLASS3"
+        ]
+    }
+
+    FAKE_ASSOCIATED_TRAITS = {
+        "traits": [
+            "CUSTOM_HW_FPGA_CLASS1",
+            "CUSTOM_HW_FPGA_CLASS2"
+        ]
+    }
+
+    def test_list_traits(self):
+        self.check_service_client_function(
+            self.client.list_traits,
+            'tempest.lib.common.rest_client.RestClient.get',
+            self.FAKE_ALL_TRAITS)
+
+        self.check_service_client_function(
+            self.client.list_traits,
+            'tempest.lib.common.rest_client.RestClient.get',
+            self.FAKE_ASSOCIATED_TRAITS,
+            **{
+                "associated": "true"
+            })
+
+        self.check_service_client_function(
+            self.client.list_traits,
+            'tempest.lib.common.rest_client.RestClient.get',
+            self.FAKE_ALL_TRAITS,
+            **{
+                "associated": "true",
+                "name": "startswith:CUSTOM_HW_FPGPA"
+            })
+
+    def test_show_traits(self):
+        self.check_service_client_function(
+            self.client.show_trait,
+            'tempest.lib.common.rest_client.RestClient.get',
+            204, status=204,
+            name="CUSTOM_HW_FPGA_CLASS1")
+
+        self.check_service_client_function(
+            self.client.show_trait,
+            'tempest.lib.common.rest_client.RestClient.get',
+            404, status=404,
+            # trait with this name does not exists
+            name="CUSTOM_HW_FPGA_CLASS4")
+
+    def test_create_traits(self):
+        self.check_service_client_function(
+            self.client.create_trait,
+            'tempest.lib.common.rest_client.RestClient.put',
+            204, status=204,
+            # try to create trait with existing name
+            name="CUSTOM_HW_FPGA_CLASS1")
+
+        self.check_service_client_function(
+            self.client.create_trait,
+            'tempest.lib.common.rest_client.RestClient.put',
+            201, status=201,
+            # create new trait
+            name="CUSTOM_HW_FPGA_CLASS4")
+
+    def test_delete_traits(self):
+        self.check_service_client_function(
+            self.client.delete_trait,
+            'tempest.lib.common.rest_client.RestClient.delete',
+            204, status=204,
+            name="CUSTOM_HW_FPGA_CLASS1")
diff --git a/tempest/tests/lib/services/placement/test_resource_providers_client.py b/tempest/tests/lib/services/placement/test_resource_providers_client.py
index 2871395..399f323 100644
--- a/tempest/tests/lib/services/placement/test_resource_providers_client.py
+++ b/tempest/tests/lib/services/placement/test_resource_providers_client.py
@@ -204,3 +204,40 @@
 
     def test_show_resource_provider_usages_with_with_bytes_body(self):
         self._test_list_resource_provider_inventories(bytes_body=True)
+
+    FAKE_ALL_RESOURCE_PROVIDER_TRAITS = {
+        "resource_provider_generation": 0,
+        "traits": [
+            "CUSTOM_HW_FPGA_CLASS1",
+            "CUSTOM_HW_FPGA_CLASS2"
+        ]
+    }
+    FAKE_NEW_RESOURCE_PROVIDER_TRAITS = {
+        "resource_provider_generation": 1,
+        "traits": [
+            "CUSTOM_HW_FPGA_CLASS1",
+            "CUSTOM_HW_FPGA_CLASS2"
+        ]
+    }
+
+    def test_list_resource_provider_traits(self):
+        self.check_service_client_function(
+            self.client.list_resource_provider_traits,
+            'tempest.lib.common.rest_client.RestClient.get',
+            self.FAKE_ALL_RESOURCE_PROVIDER_TRAITS,
+            rp_uuid=self.FAKE_RESOURCE_PROVIDER_UUID)
+
+    def test_update_resource_provider_traits(self):
+        self.check_service_client_function(
+            self.client.update_resource_provider_traits,
+            'tempest.lib.common.rest_client.RestClient.put',
+            self.FAKE_NEW_RESOURCE_PROVIDER_TRAITS,
+            rp_uuid=self.FAKE_RESOURCE_PROVIDER_UUID,
+            **self.FAKE_NEW_RESOURCE_PROVIDER_TRAITS)
+
+    def test_delete_resource_provider_traits(self):
+        self.check_service_client_function(
+            self.client.delete_resource_provider_traits,
+            'tempest.lib.common.rest_client.RestClient.delete',
+            self.FAKE_ALL_RESOURCE_PROVIDER_TRAITS, status=204,
+            rp_uuid=self.FAKE_RESOURCE_PROVIDER_UUID)