Merge "Update integrated template to run grenade-skip-level-always on stable/2026.1"
diff --git a/releasenotes/notes/add-volume-replication-config-option-e3f7a9c2b5d4f8e1.yaml b/releasenotes/notes/add-volume-replication-config-option-e3f7a9c2b5d4f8e1.yaml
new file mode 100644
index 0000000..77ad702
--- /dev/null
+++ b/releasenotes/notes/add-volume-replication-config-option-e3f7a9c2b5d4f8e1.yaml
@@ -0,0 +1,8 @@
+---
+features:
+  - |
+    A new config option ``replication`` in the ``volume-feature-enabled``
+    section is added. This boolean option (default: False) allows toggling
+    of Cinder volume replication-related test execution. When enabled, it
+    indicates that the deployment supports volume replication features and
+    replication tests should be executed.
diff --git a/releasenotes/notes/cinder-replication-support-a7b2c4d8e9f1a3b5.yaml b/releasenotes/notes/cinder-replication-support-a7b2c4d8e9f1a3b5.yaml
new file mode 100644
index 0000000..e8cec72
--- /dev/null
+++ b/releasenotes/notes/cinder-replication-support-a7b2c4d8e9f1a3b5.yaml
@@ -0,0 +1,13 @@
+---
+features:
+  - |
+    Added support for the Cinder ``failover_host`` admin action in the
+    volume v3 services client. This API allows triggering a failover
+    operation on a Cinder backend host, which is essential for testing
+    volume replication and disaster recovery scenarios.
+  - |
+    Added a new waiter function ``wait_for_volume_replication_status()`` in
+    the ``tempest.common.waiters`` module. This waiter polls a volume until
+    it reaches the expected replication_status value, with configurable
+    timeout and interval. This is useful for validating replication state
+    transitions such as 'enabled', 'copying', 'active', and 'failed-over'.
diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst
index 29ec4d5..3e88a93 100644
--- a/releasenotes/source/index.rst
+++ b/releasenotes/source/index.rst
@@ -6,6 +6,8 @@
    :maxdepth: 1
 
    unreleased
+   v46.2.0
+   v46.1.0
    v46.0.0
    v45.0.0
    v44.0.0
diff --git a/releasenotes/source/v46.1.0.rst b/releasenotes/source/v46.1.0.rst
new file mode 100644
index 0000000..dc0453d
--- /dev/null
+++ b/releasenotes/source/v46.1.0.rst
@@ -0,0 +1,6 @@
+=====================
+v46.1.0 Release Notes
+=====================
+
+.. release-notes:: 46.1.0 Release Notes
+   :version: 46.1.0
diff --git a/releasenotes/source/v46.2.0.rst b/releasenotes/source/v46.2.0.rst
new file mode 100644
index 0000000..8b5f085
--- /dev/null
+++ b/releasenotes/source/v46.2.0.rst
@@ -0,0 +1,6 @@
+=====================
+v46.2.0 Release Notes
+=====================
+
+.. release-notes:: 46.2.0 Release Notes
+   :version: 46.2.0
diff --git a/tempest/common/waiters.py b/tempest/common/waiters.py
index b4312b7..fa01dc9 100644
--- a/tempest/common/waiters.py
+++ b/tempest/common/waiters.py
@@ -433,6 +433,29 @@
              'seconds', attachment_id, volume_id, time.time() - start)
 
 
+def wait_for_volume_replication_status(client, volume_id, expected_status):
+    """Waits for a volume to reach the expected replication_status."""
+    start = int(time.time())
+    volume = client.show_volume(volume_id)['volume']
+    current_status = volume['replication_status']
+
+    while current_status != expected_status:
+        if int(time.time()) - start >= client.build_timeout:
+            message = ('Timeout waiting for volume %s to reach '
+                       'replication_status "%s". Last known status: "%s" '
+                       '(waited %s seconds).' %
+                       (volume_id, expected_status, current_status,
+                        client.build_timeout))
+            raise lib_exc.TimeoutException(message)
+
+        time.sleep(client.build_interval)
+        volume = client.show_volume(volume_id)['volume']
+        current_status = volume['replication_status']
+
+    LOG.info('Volume %s reached replication_status "%s" after %f seconds',
+             volume_id, expected_status, time.time() - start)
+
+
 def wait_for_volume_attachment_remove_from_server(
         client, server_id, volume_id):
     """Waits for a volume to be removed from a given server.
diff --git a/tempest/config.py b/tempest/config.py
index f176218..05f323f 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -1125,6 +1125,9 @@
     cfg.BoolOpt('manage_volume',
                 default=False,
                 help='Runs Cinder manage volume tests'),
+    cfg.BoolOpt('replication',
+                default=False,
+                help='Runs Cinder volume replication tests'),
     cfg.ListOpt('api_extensions',
                 default=['all'],
                 help='A list of enabled volume extensions with a special '
diff --git a/tempest/lib/api_schema/response/volume/services.py b/tempest/lib/api_schema/response/volume/services.py
index 216631c..849806b 100644
--- a/tempest/lib/api_schema/response/volume/services.py
+++ b/tempest/lib/api_schema/response/volume/services.py
@@ -86,3 +86,4 @@
 
 freeze_host = {'status_code': [200]}
 thaw_host = {'status_code': [200]}
+failover_host = {'status_code': [202]}
diff --git a/tempest/lib/services/volume/v3/services_client.py b/tempest/lib/services/volume/v3/services_client.py
index 1111f81..ca01402 100644
--- a/tempest/lib/services/volume/v3/services_client.py
+++ b/tempest/lib/services/volume/v3/services_client.py
@@ -109,3 +109,15 @@
         resp, body = self.put('os-services/thaw', put_body)
         self.validate_response(schema.thaw_host, resp, body)
         return rest_client.ResponseBody(resp)
+
+    def failover_host(self, **kwargs):
+        """Failover a Cinder Backend Host.
+
+        For a full list of available parameters, please refer to the official
+        API reference:
+        https://docs.openstack.org/api-ref/block-storage/v3/#failover-a-cinder-backend-host
+        """
+        put_body = json.dumps(kwargs)
+        resp, body = self.put('os-services/failover_host', put_body)
+        self.validate_response(schema.failover_host, resp, body)
+        return rest_client.ResponseBody(resp)
diff --git a/tempest/tests/common/test_waiters.py b/tempest/tests/common/test_waiters.py
old mode 100755
new mode 100644
index f7f2dc7..dcb0de8
--- a/tempest/tests/common/test_waiters.py
+++ b/tempest/tests/common/test_waiters.py
@@ -644,6 +644,42 @@
         # Assert that show volume is only called once before we return
         show_volume.assert_called_once_with(uuids.volume_id)
 
+    def test_wait_for_volume_replication_status(self):
+        vol_replicating = {'volume': {'replication_status': 'copying'}}
+        vol_active = {'volume': {'replication_status': 'active'}}
+        show_volume = mock.MagicMock(side_effect=[
+            vol_replicating,
+            vol_replicating,
+            vol_active])
+        client = mock.Mock(spec=volumes_client.VolumesClient,
+                           resource_type="volume",
+                           build_interval=1,
+                           build_timeout=5,
+                           show_volume=show_volume)
+        self.patch('time.time', return_value=0.)
+        self.patch('time.sleep')
+        waiters.wait_for_volume_replication_status(
+            client, mock.sentinel.volume_id, 'active')
+        # Assert that show volume is called until the expected status is
+        # reached
+        show_volume.assert_has_calls([mock.call(mock.sentinel.volume_id),
+                                      mock.call(mock.sentinel.volume_id),
+                                      mock.call(mock.sentinel.volume_id)])
+
+    def test_wait_for_volume_replication_status_timeout(self):
+        vol_replicating = {'volume': {'replication_status': 'copying'}}
+        show_volume = mock.MagicMock(return_value=vol_replicating)
+        client = mock.Mock(spec=volumes_client.VolumesClient,
+                           resource_type="volume",
+                           build_interval=1,
+                           build_timeout=1,
+                           show_volume=show_volume)
+        self.patch('time.time', side_effect=[0., client.build_timeout + 1.])
+        self.patch('time.sleep')
+        self.assertRaises(lib_exc.TimeoutException,
+                          waiters.wait_for_volume_replication_status,
+                          client, mock.sentinel.volume_id, 'active')
+
     def test_wait_for_volume_attachment_remove_from_server(self):
         volume_attached = {
             "volumeAttachments": [{"volumeId": uuids.volume_id}]}
diff --git a/tempest/tests/lib/services/volume/v3/test_services_client.py b/tempest/tests/lib/services/volume/v3/test_services_client.py
index c807bc2..c8d9a9a 100644
--- a/tempest/tests/lib/services/volume/v3/test_services_client.py
+++ b/tempest/tests/lib/services/volume/v3/test_services_client.py
@@ -173,6 +173,16 @@
             bytes_body,
             **kwargs)
 
+    def _test_failover_host(self, bytes_body=False):
+        kwargs = {'host': 'host1@lvm', 'backend_id': 'backend2'}
+        self.check_service_client_function(
+            self.client.failover_host,
+            'tempest.lib.common.rest_client.RestClient.put',
+            {},
+            bytes_body,
+            status=202,
+            **kwargs)
+
     def test_list_services_with_str_body(self):
         self._test_list_services()
 
@@ -212,3 +222,9 @@
 
     def test_thaw_host_with_bytes_body(self):
         self._test_thaw_host(bytes_body=True)
+
+    def test_failover_host_with_str_body(self):
+        self._test_failover_host()
+
+    def test_failover_host_with_bytes_body(self):
+        self._test_failover_host(bytes_body=True)
diff --git a/tools/generate-tempest-plugins-list.py b/tools/generate-tempest-plugins-list.py
index 0690d57..a22e8a4 100644
--- a/tools/generate-tempest-plugins-list.py
+++ b/tools/generate-tempest-plugins-list.py
@@ -83,6 +83,9 @@
     # in this plugin was 7 years ago.
     # https://opendev.org/airship/tempest-plugin
     'airship/tempest-plugin'
+    # It is broken by the below change:
+    # https://review.opendev.org/c/x/networking-cisco/+/969038
+    'x/networking-cisco'
 ]
 
 url = 'https://review.opendev.org/projects/'