Merge "Validate automatic lessee"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..62b24c9
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,66 @@
+---
+default_language_version:
+  # force all unspecified python hooks to run python3
+  python: python3
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.5.0
+    hooks:
+      - id: trailing-whitespace
+        # NOTE(JayF): We shouldn't modify release notes after their
+        #  associated release. Instead, ignore these minor lint issues.
+      - id: mixed-line-ending
+        args: ['--fix', 'lf']
+        exclude: |
+          (?x)(
+          .*.svg$|
+          )
+      - id: fix-byte-order-marker
+      - id: check-merge-conflict
+      - id: debug-statements
+      - id: check-json
+        files: .*\.json$
+      - id: check-yaml
+        files: .*\.(yaml|yml)$
+        exclude: releasenotes/.*$
+  - repo: https://github.com/Lucas-C/pre-commit-hooks
+    rev: v1.5.4
+    hooks:
+      - id: remove-tabs
+        exclude: '.*\.(svg)$'
+  - repo: https://opendev.org/openstack/hacking
+    rev: 6.1.0
+    hooks:
+      - id: hacking
+        additional_dependencies: []
+        exclude: '^(doc|releasenotes|tools)/.*$'
+  - repo: https://github.com/codespell-project/codespell
+    rev: v2.4.1
+    hooks:
+    - id: codespell
+      args: [--write-changes]
+  - repo: https://github.com/sphinx-contrib/sphinx-lint
+    rev: v1.0.0
+    hooks:
+      - id: sphinx-lint
+        args: [--enable=default-role]
+        files: ^doc/|releasenotes|api-ref
+  - repo: https://opendev.org/openstack/bashate
+    rev: 2.1.0
+    hooks:
+      - id: bashate
+        args: ["-iE006,E044", "-eE005,E042"]
+        name: bashate
+        description: This hook runs bashate for linting shell scripts
+        entry: bashate
+        language: python
+        types: [shell]
+  - repo: https://github.com/PyCQA/doc8
+    rev: v1.1.2
+    hooks:
+      - id: doc8
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.7.3
+    hooks:
+      - id: ruff
+        args: ['--fix', '--unsafe-fixes']
diff --git a/doc/source/conf.py b/doc/source/conf.py
old mode 100755
new mode 100644
diff --git a/ironic_tempest_plugin/common/waiters.py b/ironic_tempest_plugin/common/waiters.py
index e538cd8..cd13fe7 100644
--- a/ironic_tempest_plugin/common/waiters.py
+++ b/ironic_tempest_plugin/common/waiters.py
@@ -180,7 +180,7 @@
         field_value = node[field]
         if raise_if_insufficent_access and '** Redacted' in field_value:
             msg = ('Unable to see contents of redacted field '
-                   'indicating insufficent access to execute this test.')
+                   'indicating insufficient access to execute this test.')
             raise lib_exc.InsufficientAPIAccess(msg)
         return value in field_value
 
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index a728bef..1dce427 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -98,7 +98,7 @@
                help="Timeout for association of Nova instance and Ironic "
                     "node"),
     cfg.IntOpt('inspect_timeout',
-               default=10,
+               default=300,
                help="Timeout for inspecting an Ironic node."),
     cfg.IntOpt('power_timeout',
                default=60,
@@ -203,6 +203,9 @@
     cfg.ListOpt('enabled_power_interfaces',
                 default=['fake', 'ipmitool'],
                 help="List of Ironic enabled power interfaces."),
+    cfg.ListOpt('enabled_inspect_interfaces',
+                default=['no-inspect'],
+                help="List of Ironic enabled inspect interfaces."),
     cfg.StrOpt('default_rescue_interface',
                help="Ironic default rescue interface."),
     cfg.StrOpt('firmware_image_url',
@@ -279,6 +282,10 @@
                      "support for embedded network metadata through glean "
                      "or cloud-init, and thus cannot be executed with "
                      "most default job configurations."),
+    cfg.BoolOpt('trunks_supported',
+                default=False,
+                help="Define if trunks are supported by networking driver "
+                     "with baremetal nodes."),
 ]
 
 BaremetalIntrospectionGroup = [
diff --git a/ironic_tempest_plugin/exceptions.py b/ironic_tempest_plugin/exceptions.py
index 865ab08..a1a9873 100644
--- a/ironic_tempest_plugin/exceptions.py
+++ b/ironic_tempest_plugin/exceptions.py
@@ -30,5 +30,5 @@
 
 
 class InsufficientAPIAccess(exceptions.TempestException):
-    message = ("Insufficent Access to the API exists. Please use a user "
+    message = ("Insufficient Access to the API exists. Please use a user "
                "with an elevated level of access to execute this test.")
diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py
index f23310f..a4277b2 100644
--- a/ironic_tempest_plugin/services/baremetal/base.py
+++ b/ironic_tempest_plugin/services/baremetal/base.py
@@ -23,6 +23,11 @@
 # separate processes so global variables are not shared among them.
 BAREMETAL_MICROVERSION = None
 
+# Interfaces that can be set via the baremetal client and by logic in scenario
+# managers.
+SUPPORTED_INTERFACES = ['bios', 'deploy', 'rescue', 'boot', 'raid',
+                        'management', 'power', 'inspect']
+
 
 def set_baremetal_api_microversion(baremetal_microversion):
     global BAREMETAL_MICROVERSION
@@ -74,8 +79,18 @@
 
     def get_min_max_api_microversions(self):
         """Returns a tuple of minimum and remote microversions."""
-        _, resp_body = self._show_request(None, uri='/')
-        version = resp_body.get('default_version', {})
+        if '/v1' in self.base_url:
+            root_uri = '/'
+        else:
+            # NOTE(dtantsur): we should just use / here but due to a bug in
+            # Ironic, / does not contain the microversion headers. See
+            # https://bugs.launchpad.net/ironic/+bug/2079023
+            root_uri = '/v1'
+        _, resp_body = self._show_request(None, uri=root_uri)
+        try:
+            version = resp_body['default_version']
+        except KeyError:
+            version = resp_body['version']
         api_min = version.get('min_version')
         api_max = version.get('version')
         return (api_min, api_max)
diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
index 5715609..182cacc 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -20,8 +20,28 @@
     version = '1'
     uri_prefix = 'v1'
 
-    @staticmethod
-    def _get_headers(api_version):
+    node_attributes = (
+        'properties/cpu_arch',
+        'properties/cpus',
+        'properties/local_gb',
+        'properties/memory_mb',
+        'driver',
+        'instance_uuid',
+        'resource_class',
+        'protected',
+        'protected_reason',
+        # TODO(dtantsur): maintenance is set differently
+        # in newer API versions.
+        'maintenance',
+        'description',
+        'shard'
+    ) + tuple(
+        f'{iface}_interface'
+        for iface in base.SUPPORTED_INTERFACES
+    )
+
+    @classmethod
+    def _get_headers(cls, api_version):
         """Return headers for a request.
 
         Currently supports a header specifying the API version to use.
@@ -35,7 +55,7 @@
         headers = None
         if api_version is not None:
             extra_headers = True
-            headers = {'x-openstack-ironic-api-version': api_version}
+            headers = {cls.api_microversion_header_name: api_version}
         return extra_headers, headers
 
     @base.handle_errors
@@ -119,6 +139,11 @@
         return self._list_request('deploy_templates', **kwargs)
 
     @base.handle_errors
+    def list_runbooks(self, **kwargs):
+        """List all runbooks."""
+        return self._list_request('runbooks', **kwargs)
+
+    @base.handle_errors
     def show_node(self, uuid, api_version=None):
         """Gets a specific node.
 
@@ -247,6 +272,14 @@
         """
         return self._show_request('deploy_templates', deploy_template_ident)
 
+    def show_runbook(self, runbook_ident):
+        """Gets a specific runbook.
+
+        :param runbook_ident: Name or UUID of runbook.
+        :return: Serialized runbook as a dictionary.
+        """
+        return self._show_request('runbooks', runbook_ident)
+
     @base.handle_errors
     def create_node(self, chassis_id=None, **kwargs):
         """Create a baremetal node with the specified parameters.
@@ -424,6 +457,23 @@
         return self._create_request('deploy_templates', kwargs)
 
     @base.handle_errors
+    def create_runbook(self, name, **kwargs):
+        """Create a runbook with the specified parameters.
+
+        :param name: The name of the runbook.
+        :param kwargs:
+            steps: steps of the runbook.
+            uuid: UUID of the runbook. Optional.
+            public: An optional boolean value indicating whether the runbook
+                is public (accessible to others)
+                or private (restricted to the owner).
+            extra: meta-data of the runbook. Optional.
+        :return: A tuple with the server response and the created runbook.
+        """
+        kwargs['name'] = name
+        return self._create_request('runbooks', kwargs)
+
+    @base.handle_errors
     def delete_node(self, uuid):
         """Deletes a node having the specified UUID.
 
@@ -491,6 +541,15 @@
         return self._delete_request('deploy_templates', deploy_template_ident)
 
     @base.handle_errors
+    def delete_runbook(self, runbook_ident):
+        """Deletes a runbook having the specified name or UUID.
+
+        :param runbook_ident: Name or UUID of the runbook.
+        :return: A tuple with the server response and the response body.
+        """
+        return self._delete_request('runbooks', runbook_ident)
+
+    @base.handle_errors
     def update_node(self, uuid, patch=None, **kwargs):
         """Update the specified node.
 
@@ -507,26 +566,8 @@
         else:
             params = {}
 
-        node_attributes = ('properties/cpu_arch',
-                           'properties/cpus',
-                           'properties/local_gb',
-                           'properties/memory_mb',
-                           'driver',
-                           'bios_interface',
-                           'deploy_interface',
-                           'raid_interface',
-                           'rescue_interface',
-                           'instance_uuid',
-                           'resource_class',
-                           'protected',
-                           'protected_reason',
-                           # TODO(dtantsur): maintenance is set differently
-                           # in newer API versions.
-                           'maintenance',
-                           'description',
-                           'shard')
         if not patch:
-            patch = self._make_patch(node_attributes, **kwargs)
+            patch = self._make_patch(self.node_attributes, **kwargs)
 
         return self._patch_request('nodes', uuid, patch, params=params)
 
@@ -596,6 +637,18 @@
                                    patch)
 
     @base.handle_errors
+    def update_runbook(self, runbook_ident, patch):
+        """Update the specified runbook.
+
+        :param runbook_ident: Name or UUID of the runbook.
+        :param patch: List of dicts representing json patches. Each dict
+            has keys 'path', 'op' and 'value'; to update a field.
+        :return: A tuple with the server response and the updated runbook.
+        """
+
+        return self._patch_request('runbooks', runbook_ident, patch)
+
+    @base.handle_errors
     def set_node_power_state(self, node_uuid, state):
         """Set power state of the specified node.
 
@@ -622,7 +675,8 @@
 
     @base.handle_errors
     def set_node_provision_state(self, node_uuid, state, configdrive=None,
-                                 clean_steps=None, rescue_password=None):
+                                 clean_steps=None, rescue_password=None,
+                                 runbook=None):
         """Set provision state of the specified node.
 
         :param node_uuid: The unique identifier of the node.
@@ -632,6 +686,7 @@
             configuration drive string.
         :param clean_steps: A list with clean steps to execute.
         :param rescue_password: user password used to rescue.
+        :param runbook: The unique identifier of a runbook.
         """
         data = {'target': state}
         # NOTE (vsaienk0): Add both here if specified, do not check anything.
@@ -642,6 +697,8 @@
             data['clean_steps'] = clean_steps
         if rescue_password is not None:
             data['rescue_password'] = rescue_password
+        if runbook is not None:
+            data['runbook'] = runbook
         return self._put_request('nodes/%s/states/provision' % node_uuid,
                                  data)
 
@@ -782,7 +839,7 @@
         headers = None
         if api_version is not None:
             extra_headers = True
-            headers = {'x-openstack-ironic-api-version': api_version}
+            headers = {self.api_microversion_header_name: api_version}
         return self._list_request('nodes/%s/vifs' % node_uuid,
                                   headers=headers,
                                   extra_headers=extra_headers)
@@ -951,3 +1008,35 @@
         }
 
         return self._create_request_no_response_body('heartbeat', kwargs)
+
+    @base.handle_errors
+    def show_inventory(self, uuid, api_version='1.81'):
+        """Gets hardware inventory for the specific node.
+
+        :param uuid: Unique identifier of the node in UUID format.
+        :param api_version: Ironic API version to use.
+        :return: Inventory as a dictionary.
+
+        """
+        extra_headers, headers = self._get_headers(api_version)
+        resp, body = self._show_request(
+            'inventory', uuid, headers=headers, extra_headers=extra_headers,
+            uri=f'{self.uri_prefix}/nodes/{uuid}/inventory')
+        self.expected_success(http_client.OK, resp.status)
+        return body
+
+    @base.handle_errors
+    def get_shards(self, api_version='1.82'):
+        """Get all shards."""
+
+        extra_headers, headers = self._get_headers(api_version)
+        return self._list_request('shards', headers=headers,
+                                  extra_headers=extra_headers)
+
+    @base.handle_errors
+    def list_node_firmware(self, node_uuid):
+        """List firmware for a node.
+
+        :param node_uuid: The unique identifier of the node.
+        """
+        return self._list_request('/nodes/%s/firmware' % node_uuid)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_microversion_enforcement.py b/ironic_tempest_plugin/tests/api/admin/test_microversion_enforcement.py
new file mode 100644
index 0000000..ebd7451
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_microversion_enforcement.py
@@ -0,0 +1,158 @@
+# 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.
+
+from oslo_utils import uuidutils
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin.services.baremetal.v1.json.baremetal_client import \
+    BaremetalClient
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api import base
+
+
+class MicroversionTestMixin:
+    """Mixin class containing shared microversion test functionality."""
+
+    def _microversion_test(
+            self, method_name, min_version, expected_error, required_args):
+        """Test methods with invalid API versions"""
+
+        major, minor = map(int, min_version.split('.'))
+        # Set limits, as lowest microversion is 1.1
+        if minor <= 10:
+            minor = 11
+        if minor <= 1:
+            minor = 2
+        invalid_versions = [
+            f"{major}.{minor - 1}",
+            f"{major}.{minor - 10}",
+        ]
+
+        # Get method name from method object
+        method_name = method_name.__name__
+
+        for microversion in invalid_versions:
+            for arg_name, arg_value in required_args.items():
+                msg = (
+                    f"Testing {method_name} with version {microversion} "
+                    f"and argument {arg_name}={arg_value}"
+                )
+                with self.subTest(
+                    msg=msg, method=method_name, version=microversion
+                ):
+                    self.useFixture(
+                        api_microversion_fixture.APIMicroversionFixture(
+                            microversion
+                        )
+                    )
+                    method = getattr(self.client, method_name)
+
+                    self.assertRaises(
+                        expected_error,
+                        method,
+                        **{arg_name: arg_value},
+                    )
+
+
+class BaseTestMicroversionEnforcement(base.BaseBaremetalTest):
+    """Base class for microversion enforcement tests."""
+
+    def setUp(self):
+        super(BaseTestMicroversionEnforcement, self).setUp()
+        self.resource_class = uuidutils.generate_uuid()
+
+
+class TestShardMicroversions(
+        BaseTestMicroversionEnforcement,
+        MicroversionTestMixin):
+    """Tests for shard-related API microversion enforcement."""
+
+    min_microversion = "1.82"
+
+    @decorators.idempotent_id("e5403a31-e12b-4f97-a776-dcb819e5e9a0")
+    def test_shard(self):
+        self._microversion_test(
+            BaremetalClient.get_shards, "1.82", lib_exc.NotFound, {}
+        )
+
+    @decorators.idempotent_id("5df533c6-7a9c-4639-a47f-1377a2a87e6a")
+    def test_list_node_filter_shard(self):
+        self._microversion_test(
+            BaremetalClient.list_nodes, "1.82",
+            lib_exc.NotAcceptable, {"shard": "testshard"}
+        )
+
+
+class TestAllocationMicroversions(
+        BaseTestMicroversionEnforcement,
+        MicroversionTestMixin):
+    """Tests for allocation-related API microversion enforcement."""
+
+    min_microversion = "1.52"
+
+    @decorators.idempotent_id('8f527b3d-d5f1-4859-920f-8022b5d13621')
+    def test_create_allocations(self):
+        self._microversion_test(
+            BaremetalClient.create_allocation, "1.52",
+            lib_exc.UnexpectedResponseCode, {
+                "resource_class": self.resource_class
+            }
+        )
+
+    @decorators.idempotent_id('511e0c4b-1320-4ac5-9c4a-fb0394d3ff67')
+    def test_list_allocations(self):
+        self._microversion_test(
+            BaremetalClient.list_allocations, "1.52",
+            lib_exc.NotFound, {}
+        )
+
+    @decorators.idempotent_id('a0d17f90-baa0-4518-95f7-a7eab73ff6d1')
+    def test_show_allocations(self):
+        _, allocation = self.create_allocation(self.resource_class)
+
+        self._microversion_test(
+            BaremetalClient.show_allocation, "1.52",
+            lib_exc.NotFound, {
+                "allocation_ident": allocation['uuid']
+            }
+        )
+
+    @decorators.idempotent_id('b05a9b1a-4a12-4b55-93c7-530c3f35c7d9')
+    def test_delete_allocations(self):
+        _, allocation = self.create_allocation(self.resource_class)
+
+        self._microversion_test(
+            BaremetalClient.delete_allocation, "1.52",
+            lib_exc.UnexpectedResponseCode, {
+                "allocation_ident": allocation['uuid']
+            }
+        )
+
+
+class TestNodeFirmwarenMicroversions(
+        BaseTestMicroversionEnforcement,
+        MicroversionTestMixin):
+
+    min_microversion = "1.86"
+
+    def setUp(self):
+        super(TestNodeFirmwarenMicroversions, self).setUp()
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.idempotent_id('f50e9098-1870-46b1-b05c-660d0f8c534d')
+    def test_list_node_firmware(self):
+        self._microversion_test(
+            BaremetalClient.list_node_firmware, "1.86",
+            lib_exc.NotFound, {"node_uuid": self.node['uuid']}
+        )
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodes.py b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
index 6d63768..3da3f3e 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_nodes.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
@@ -372,7 +372,7 @@
     def test_create_node_resource_class_old_api(self):
         """Try to create a node with resource class using older api version."""
         resource_class = data_utils.arbitrary_string()
-        self.assertRaises(lib_exc.UnexpectedResponseCode, self.create_node,
+        self.assertRaises(lib_exc.NotAcceptable, self.create_node,
                           self.chassis['uuid'], resource_class=resource_class)
 
     @decorators.attr(type='negative')
@@ -380,7 +380,7 @@
     def test_update_node_resource_class_old_api(self):
         """Try to update a node with resource class using older api version."""
         resource_class = data_utils.arbitrary_string()
-        self.assertRaises(lib_exc.UnexpectedResponseCode,
+        self.assertRaises(lib_exc.NotAcceptable,
                           self.client.update_node,
                           self.node['uuid'], resource_class=resource_class)
 
@@ -390,10 +390,10 @@
         """Try to list nodes with resource class using older api version."""
         resource_class = data_utils.arbitrary_string()
         self.assertRaises(
-            lib_exc.UnexpectedResponseCode,
+            lib_exc.NotAcceptable,
             self.client.list_nodes, resource_class=resource_class)
         self.assertRaises(
-            lib_exc.UnexpectedResponseCode,
+            lib_exc.NotAcceptable,
             self.client.list_nodes_detail, resource_class=resource_class)
 
 
@@ -1043,10 +1043,9 @@
     @decorators.idempotent_id('5419af7b-4e27-4be4-88f6-e01c598a8102')
     def test_list_node_traits_old_api(self):
         """Try to list traits for a node using an older api version."""
-        exc = self.assertRaises(
-            lib_exc.UnexpectedResponseCode,
+        self.assertRaises(
+            lib_exc.NotAcceptable,
             self.client.list_node_traits, self.node['uuid'])
-        self.assertEqual(406, exc.resp.status)
 
     @decorators.attr(type='negative')
     @decorators.idempotent_id('a4353f3a-bedc-4579-9c7e-4bebcd95903d')
@@ -1096,10 +1095,9 @@
     @decorators.idempotent_id('eb75b3c8-ac9c-4399-90a2-c0030bfde7a6')
     def test_list_nodes_traits_field(self):
         """Try to list nodes' traits field using older api version."""
-        exc = self.assertRaises(
-            lib_exc.UnexpectedResponseCode,
+        self.assertRaises(
+            lib_exc.NotAcceptable,
             self.client.list_nodes, fields='traits')
-        self.assertEqual(406, exc.resp.status)
 
     @decorators.attr(type='negative')
     @decorators.idempotent_id('214ae7fc-149b-4657-b6bc-66353d49ade8')
diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports.py b/ironic_tempest_plugin/tests/api/admin/test_ports.py
index dfb371c..9bf2771 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_ports.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_ports.py
@@ -25,9 +25,23 @@
         super(TestPorts, self).setUp()
 
         _, self.chassis = self.create_chassis()
+        # NOTE(TheJulia): noop is required for the network interface for
+        # these tests, as newer versions of ironic can invoke network
+        # logic, and if is not configured with full integration configuration,
+        # can then result in failed tests.
         _, self.node = self.create_node(self.chassis['uuid'])
         _, self.port = self.create_port(self.node['uuid'],
                                         data_utils.rand_mac_address())
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.31'))
+        # Now with a 1.31 microversion, swap the network interfaces
+        # into place so the test doesn't break depending on
+        # the environment's default state.
+        self.client.update_node(self.node['uuid'],
+                                [{'path': '/network_interface',
+                                  'op': 'replace',
+                                  'value': 'noop'}])
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.1'))
 
     @decorators.idempotent_id('83975898-2e50-42ed-b5f0-e510e36a0b56')
     def test_create_port(self):
diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py
index bd338f9..fb1913c 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py
@@ -26,6 +26,16 @@
 
         _, self.chassis = self.create_chassis()
         _, self.node = self.create_node(self.chassis['uuid'])
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.31'))
+        # Now with a 1.31 microversion, swap the network interfaces
+        # into place so the test doesn't break depending on
+        # the environment's default state.
+        self.client.update_node(self.node['uuid'],
+                                [{'path': '/network_interface',
+                                  'op': 'replace',
+                                  'value': 'noop'}])
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.1'))
 
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('0a6ee1f7-d0d9-4069-8778-37f3aa07303a')
@@ -354,7 +364,7 @@
         node_id = self.node['uuid']
         address = data_utils.rand_mac_address()
 
-        self.assertRaises((lib_exc.BadRequest, lib_exc.UnexpectedResponseCode),
+        self.assertRaises((lib_exc.BadRequest, lib_exc.NotAcceptable),
                           self.create_port,
                           node_id=node_id, address=address,
                           physical_network='physnet1')
@@ -371,7 +381,7 @@
                   'op': 'replace',
                   'value': new_physnet}]
 
-        self.assertRaises((lib_exc.BadRequest, lib_exc.UnexpectedResponseCode),
+        self.assertRaises((lib_exc.BadRequest, lib_exc.NotAcceptable),
                           self.client.update_port,
                           port['uuid'], patch)
 
diff --git a/ironic_tempest_plugin/tests/api/admin/test_runbooks.py b/ironic_tempest_plugin/tests/api/admin/test_runbooks.py
new file mode 100644
index 0000000..af3c37c
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_runbooks.py
@@ -0,0 +1,352 @@
+#    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 copy
+
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api import base
+
+
+EXAMPLE_STEPS = [{
+    'interface': 'bios',
+    'step': 'apply_configuration',
+    'args': {},
+    'order': 1
+}]
+
+
+def _get_random_trait():
+    return data_utils.rand_name('CUSTOM', '').replace('-', '_')
+
+
+class TestRunbooks(base.BaseBaremetalTest):
+    """Tests for runbooks."""
+
+    min_microversion = '1.92'
+
+    def setUp(self):
+        super(TestRunbooks, self).setUp()
+        self.name = _get_random_trait()
+        self.steps = copy.deepcopy(EXAMPLE_STEPS)
+        _, self.runbook = self.create_runbook(self.name,
+                                              steps=self.steps)
+
+        _, self.chassis = self.create_chassis()
+        _, self.test_node = self.create_node(self.chassis['uuid'])
+
+    @decorators.idempotent_id('5a0cfe27-b7e8-d4f0-d3c5-e6e1eef63241')
+    def test_create_runbook_specifying_uuid(self):
+        name = _get_random_trait()
+        uuid = data_utils.rand_uuid()
+
+        _, runbook = self.create_runbook(name=name, steps=self.steps,
+                                         uuid=uuid)
+
+        _, body = self.client.show_runbook(uuid)
+        self._assertExpected(runbook, body)
+
+    @decorators.idempotent_id('b8bfb388-97b0-aa6e-5130-1a1ed34f94db')
+    def test_delete_runbook(self):
+        self.delete_runbook(self.runbook['uuid'])
+        self.assertRaises(lib_exc.NotFound, self.client.show_runbook,
+                          self.runbook['uuid'])
+
+    @decorators.idempotent_id('f1cba93a-2894-7296-0f4b-58867359480b')
+    def test_show_runbook(self):
+        _, runbook = self.client.show_runbook(self.runbook['uuid'])
+        self._assertExpected(self.runbook, runbook)
+        self.assertEqual(self.name, runbook['name'])
+        self.assertEqual(self.steps, runbook['steps'])
+        self.assertIn('uuid', runbook)
+        self.assertEqual({}, runbook['extra'])
+
+    @decorators.idempotent_id('c95e2631-24e2-914b-db00-c8dafc35a677')
+    def test_show_runbook_with_links(self):
+        _, runbook = self.client.show_runbook(self.runbook['uuid'])
+        self.assertIn('links', runbook)
+        self.assertEqual(2, len(runbook['links']))
+        self.assertIn(runbook['uuid'], runbook['links'][0]['href'])
+
+    @decorators.idempotent_id('7b7951b3-e177-21d7-933a-1f29891dea52')
+    def test_list_runbooks(self):
+        _, body = self.client.list_runbooks()
+        self.assertIn(self.runbook['uuid'],
+                      [i['uuid'] for i in body['runbooks']])
+
+        for runbook in body['runbooks']:
+            self.validate_self_link('runbooks', runbook['uuid'],
+                                    runbook['links'][0]['href'])
+
+    @decorators.idempotent_id('6aafc619-0d98-5341-7d94-b293e194dcf7')
+    def test_list_with_limit(self):
+        for _ in range(2):
+            name = _get_random_trait()
+            self.create_runbook(name, steps=self.steps)
+
+        _, body = self.client.list_runbooks(limit=3)
+
+        next_marker = body['runbooks'][-1]['uuid']
+        self.assertIn(next_marker, body['next'])
+
+    @decorators.idempotent_id('ebd762c3-c10e-b71f-5efd-af14ac9f6092')
+    def test_list_runbooks_detail(self):
+        uuids = [
+            self.create_runbook(_get_random_trait(), steps=self.steps)
+            [1]['uuid'] for _ in range(0, 5)]
+
+        _, body = self.client.list_runbooks(detail=True)
+
+        runbooks_dict = dict((runbook['uuid'], runbook)
+                             for runbook in body['runbooks']
+                             if runbook['uuid'] in uuids)
+
+        for uuid in uuids:
+            self.assertIn(uuid, runbooks_dict)
+            runbook = runbooks_dict[uuid]
+            self.assertIn('name', runbook)
+            self.assertEqual(self.steps, runbook['steps'])
+            self.assertIn('uuid', runbook)
+            self.assertEqual({}, runbook['extra'])
+            self.validate_self_link('runbooks', runbook['uuid'],
+                                    runbook['links'][0]['href'])
+
+    @decorators.idempotent_id('fb192fdd-ea6a-c637-a36e-390c46a7663b')
+    def test_update_runbook_replace(self):
+        new_name = _get_random_trait()
+        new_steps = [{
+            'interface': 'raid',
+            'step': 'create_configuration',
+            'args': {},
+            'order': 2,
+        }]
+
+        patch = [{'path': '/name', 'op': 'replace', 'value': new_name},
+                 {'path': '/steps', 'op': 'replace', 'value': new_steps}]
+
+        self.client.update_runbook(self.runbook['uuid'], patch)
+
+        _, body = self.client.show_runbook(self.runbook['uuid'])
+        self.assertEqual(new_name, body['name'])
+        self.assertEqual(new_steps, body['steps'])
+
+    @decorators.idempotent_id('99f52546-9906-6ded-9186-7262591b99ec')
+    def test_update_runbook_add(self):
+        new_steps = [
+            {
+                'interface': 'bios',
+                'step': 'cache_bios_settings',
+                'args': {},
+                'order': 2
+            },
+            {
+                'interface': 'bios',
+                'step': 'factory_reset',
+                'args': {},
+                'order': 3
+            },
+        ]
+
+        patch = [{'path': '/steps/1', 'op': 'add', 'value': new_steps[0]},
+                 {'path': '/steps/2', 'op': 'add', 'value': new_steps[1]}]
+
+        self.client.update_runbook(self.runbook['uuid'], patch)
+
+        _, body = self.client.show_runbook(self.runbook['uuid'])
+        self.assertEqual(self.steps + new_steps, body['steps'])
+
+    @decorators.idempotent_id('ec1550c3-264e-fcce-b131-d2815fdb733b')
+    def test_update_runbook_mixed_ops(self):
+        new_name = _get_random_trait()
+        new_steps = [
+            {
+                'interface': 'bios',
+                'step': 'apply_configuration',
+                'args': {},
+                'order': 2
+            },
+            {
+                'interface': 'bios',
+                'step': 'apply_configuration',
+                'args': {},
+                'order': 3
+            },
+        ]
+
+        patch = [{'path': '/name', 'op': 'replace', 'value': new_name},
+                 {'path': '/steps/0', 'op': 'replace', 'value': new_steps[0]},
+                 {'path': '/steps/0', 'op': 'remove'},
+                 {'path': '/steps/0', 'op': 'add', 'value': new_steps[1]}]
+
+        self.client.update_runbook(self.runbook['uuid'], patch)
+
+        _, body = self.client.show_runbook(self.runbook['uuid'])
+        self.assertEqual(new_name, body['name'])
+        self.assertEqual([new_steps[1]], body['steps'])
+
+    @decorators.idempotent_id('5c7f0aca-cee3-d083-ef2a-e33a8dc467c5')
+    def test_combining_runbook_and_explicit_steps(self):
+        explicit_steps = [{'interface': 'deploy', 'step': 'deploy',
+                           'args': {}}]
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.set_node_provision_state,
+                          self.test_node['uuid'], 'active',
+                          runbook=self.runbook['uuid'],
+                          clean_steps=explicit_steps)
+
+    @decorators.idempotent_id('ddf4a9b7-144d-386a-fd3fcf8960d77199')
+    def test_create_runbook_with_invalid_step_format(self):
+        name = _get_random_trait()
+        invalid_steps = [{'invalid_key': 'value'}]
+
+        self.assertRaises(lib_exc.BadRequest, self.create_runbook,
+                          name, steps=invalid_steps)
+
+
+class TestRunbooksOldAPI(base.BaseBaremetalTest):
+    """Negative tests for runbooks using an old API version."""
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e9481a0d-23e0-4757-bc11-c3c9ab9d3839')
+    def test_create_runbook_old_api(self):
+        # With runbooks support, ironic returns 404. Without, 405.
+        self.assertRaises((lib_exc.NotFound, lib_exc.UnexpectedResponseCode),
+                          self.create_runbook,
+                          name=_get_random_trait(), steps=EXAMPLE_STEPS)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0d3af2aa-ba53-4c8a-92d4-91f9b4179fe7')
+    def test_update_runbook_old_api(self):
+        patch = [{'path': '/name', 'op': 'replace',
+                  'value': _get_random_trait()}]
+
+        # With runbooks support, ironic returns 404. Without, 405.
+        self.assertRaises((lib_exc.NotFound, lib_exc.UnexpectedResponseCode),
+                          self.client.update_runbook,
+                          _get_random_trait(), patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('1646b1e5-ab81-45a8-9ea0-30444a4dcaa2')
+    def test_delete_runbook_old_api(self):
+        # With runbooks support, ironic returns 404. Without, 405.
+        self.assertRaises((lib_exc.NotFound, lib_exc.UnexpectedResponseCode),
+                          self.client.delete_runbook,
+                          _get_random_trait())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('819480ac-f36a-4402-b1d5-504d7cf55b1f')
+    def test_list_runbooks_old_api(self):
+        self.assertRaises(lib_exc.NotFound,
+                          self.client.list_runbooks)
+
+
+class TestRunbooksNegative(base.BaseBaremetalTest):
+    """Negative tests for runbooks."""
+
+    min_microversion = '1.92'
+
+    def setUp(self):
+        super(TestRunbooksNegative, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.min_microversion)
+        )
+        self.steps = EXAMPLE_STEPS
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('a4085c08-e718-4c2f-a796-0e115b659243')
+    def test_create_runbook_invalid_name(self):
+        name = 'invalid-name'
+        self.assertRaises(lib_exc.BadRequest,
+                          self.create_runbook, name=name,
+                          steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('6390acc4-9490-4b23-8b4c-41888a78c9b7')
+    def test_create_runbook_duplicated_name(self):
+        name = _get_random_trait()
+        self.create_runbook(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.Conflict, self.create_runbook,
+                          name=name, steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ed3f0cec-13e8-4175-9fdb-d129e7b7fe10')
+    def test_create_runbook_no_mandatory_field_name(self):
+        self.assertRaises(lib_exc.BadRequest, self.create_runbook,
+                          name=None, steps=self.steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('af5dd0df-d903-463f-9535-9e4e9d6fd576')
+    def test_create_runbook_no_mandatory_field_steps(self):
+        self.assertRaises(lib_exc.BadRequest, self.create_runbook,
+                          name=_get_random_trait())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('cbd33bc5-7602-40b7-943e-3e92217567a3')
+    def test_create_runbook_malformed_steps(self):
+        steps = {'key': 'value'}
+        self.assertRaises(lib_exc.BadRequest, self.create_runbook,
+                          name=_get_random_trait(), steps=steps)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2a562fca-f377-4a6e-b332-37ee82d3a983')
+    def test_create_runbook_malformed_runbook_uuid(self):
+        uuid = 'malformed:uuid'
+        self.assertRaises(lib_exc.BadRequest, self.create_runbook,
+                          name=_get_random_trait(), steps=self.steps,
+                          uuid=uuid)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2c006994-88ca-43b7-b605-897d479229d9')
+    def test_show_runbook_nonexistent(self):
+        self.assertRaises(lib_exc.NotFound, self.client.show_runbook,
+                          data_utils.rand_uuid())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('5a815f37-f015-4d68-9b22-099504f74805')
+    def test_update_runbook_remove_mandatory_field_steps(self):
+        name = _get_random_trait()
+        _, runbook = self.create_runbook(name=name, steps=self.steps)
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_runbook,
+                          runbook['uuid'],
+                          [{'path': '/steps/0', 'op': 'remove'}])
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_runbook,
+                          runbook['uuid'],
+                          [{'path': '/steps', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ee852ebb-a601-4593-9d59-063fcbc8f964')
+    def test_update_runbook_remove_mandatory_field_name(self):
+        name = _get_random_trait()
+        _, runbook = self.create_runbook(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_runbook,
+                          runbook['uuid'],
+                          [{'path': '/name', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e59bf38d-272f-4490-b21e-9db217f11378')
+    def test_update_runbook_replace_empty_name(self):
+        name = _get_random_trait()
+        _, runbook = self.create_runbook(name=name, steps=self.steps)
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_runbook,
+                          runbook['uuid'],
+                          [{'path': '/name', 'op': 'replace', 'value': ''}])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_shards.py b/ironic_tempest_plugin/tests/api/admin/test_shards.py
index ffe6914..0638702 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_shards.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_shards.py
@@ -15,6 +15,7 @@
 
 from ironic_tempest_plugin.tests.api import base
 
+
 CONF = config.CONF
 
 
@@ -126,3 +127,25 @@
 
         self.assertIn(self.none_node_id, fetched_node_ids)
         self.assertNotIn(self.bad_node_id, fetched_node_ids)
+
+
+class TestGetAllShards(base.BaseBaremetalTest):
+    """Tests for baremetal shards."""
+
+    min_microversion = '1.82'
+
+    def setUp(self):
+        super(TestGetAllShards, self).setUp()
+        _, self.chassis = self.create_chassis()
+        self.shards = ["shard1", "shard2", "shard3"]
+        self.node_ids = []
+        for shard in self.shards:
+            _, node = self.create_node(self.chassis['uuid'], shard=shard)
+            self.node_ids.append(node['uuid'])
+
+    @decorators.idempotent_id('fc786196-63c7-4e0d-bd14-3e478d4d1e3e')
+    def test_get_all_shards(self):
+        _, fetched_shards = self.client.get_shards()
+        fetched_shards = [shard['name'] for shard in fetched_shards['shards']]
+
+        self.assertItemsEqual(self.shards, fetched_shards)
diff --git a/ironic_tempest_plugin/tests/api/base.py b/ironic_tempest_plugin/tests/api/base.py
index 59f2f94..c07137b 100644
--- a/ironic_tempest_plugin/tests/api/base.py
+++ b/ironic_tempest_plugin/tests/api/base.py
@@ -38,7 +38,7 @@
 # NOTE(jroll): resources must be deleted in a specific order, this list
 # defines the resource types to clean up, and the correct order.
 RESOURCE_TYPES = ['port', 'portgroup', 'node', 'volume_connector',
-                  'volume_target', 'chassis', 'deploy_template']
+                  'volume_target', 'chassis', 'deploy_template', 'runbook']
 
 
 def creates(resource):
@@ -368,6 +368,18 @@
         return resp, body
 
     @classmethod
+    @creates('runbook')
+    def create_runbook(cls, name, **kwargs):
+        """Wrapper utility for creating test runbook.
+
+        :param name: The name of the runbook.
+        :return: A tuple with the server response and the created runbook.
+        """
+        resp, body = cls.client.create_runbook(name=name, **kwargs)
+
+        return resp, body
+
+    @classmethod
     def delete_chassis(cls, chassis_id):
         """Deletes a chassis having the specified UUID.
 
@@ -473,6 +485,20 @@
 
         return resp
 
+    @classmethod
+    def delete_runbook(cls, runbook_ident):
+        """Deletes a runbook having the specified name or UUID.
+
+        :param runbook_ident: Name or UUID of the runbook.
+        :return: Server response.
+        """
+        resp, body = cls.client.delete_runbook(runbook_ident)
+
+        if runbook_ident in cls.created_objects['runbook']:
+            cls.created_objects['runbook'].remove(runbook_ident)
+
+        return resp
+
     def validate_self_link(self, resource, uuid, link):
         """Check whether the given self link formatted correctly."""
         expected_link = "{base}/{pref}/{res}/{uuid}".format(
@@ -497,8 +523,8 @@
 
 class BaseBaremetalRBACTest(BaseBaremetalTest):
 
-    # Unless otherwise superceeded by a version, RBAC tests generally start at
-    # version 1.70 as that is when System scope and the delineation occured.
+    # Unless otherwise superseded by a version, RBAC tests generally start at
+    # version 1.70 as that is when System scope and the delineation occurred.
     min_microversion = '1.70'
 
     @classmethod
diff --git a/ironic_tempest_plugin/tests/api/rbac_defaults/test_nodes.py b/ironic_tempest_plugin/tests/api/rbac_defaults/test_nodes.py
index cd16fe2..2af624a 100644
--- a/ironic_tempest_plugin/tests/api/rbac_defaults/test_nodes.py
+++ b/ironic_tempest_plugin/tests/api/rbac_defaults/test_nodes.py
@@ -675,7 +675,7 @@
     All tests here must always expect *multiple* nodes visible, since
     this is a global reader role.
 
-    https://opendev.org/openstack/ironic/src/branch/master/ironic/common/policy.py#L60  # noqa
+    https://opendev.org/openstack/ironic/src/branch/master/ironic/common/policy.py#L60
     """
 
     credentials = ['system_admin', 'system_reader']
diff --git a/ironic_tempest_plugin/tests/api/rbac_defaults/test_runbooks.py b/ironic_tempest_plugin/tests/api/rbac_defaults/test_runbooks.py
new file mode 100644
index 0000000..eb1476c
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/rbac_defaults/test_runbooks.py
@@ -0,0 +1,100 @@
+#    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.
+
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin.tests.api import base
+
+
+EXAMPLE_STEPS = [{
+    'interface': 'bios',
+    'step': 'apply_configuration',
+    'args': {},
+    'order': 1
+}]
+
+
+def _get_random_trait():
+    return data_utils.rand_name('CUSTOM', '').replace('-', '_')
+
+
+class TestRunbookRBAC(base.BaseBaremetalRBACTest):
+    min_microversion = '1.92'
+    credentials = ['system_admin',
+                   'system_reader',
+                   'project_admin',
+                   'project_member']
+
+    def setUp(self):
+        super(TestRunbookRBAC, self).setUp()
+        self.system_admin_client = (
+            self.os_system_admin.baremetal.BaremetalClient())
+
+        self.system_reader_client = (
+            self.os_system_reader.baremetal.BaremetalClient())
+
+        self.project_admin_client = (
+            self.os_project_admin.baremetal.BaremetalClient())
+
+        self.project_member_client = (
+            self.os_project_member.baremetal.BaremetalClient())
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+        _, self.public_runbook = self.system_admin_client.create_runbook(
+            _get_random_trait(), steps=EXAMPLE_STEPS, public=True)
+
+        _, self.private_runbook = self.project_admin_client.create_runbook(
+            _get_random_trait(), steps=EXAMPLE_STEPS)
+
+    @decorators.idempotent_id('0410fbed-3454-ded3-6f8b3192abfd3fcd')
+    def test_runbook_visibility_and_access_control(self):
+        # Project-scoped user can see both public and owned runbooks
+        _, project_runbooks = self.project_member_client.list_runbooks()
+        self.assertIn(self.public_runbook['uuid'],
+                      [r['uuid'] for r in project_runbooks['runbooks']])
+        self.assertIn(self.private_runbook['uuid'],
+                      [r['uuid'] for r in project_runbooks['runbooks']])
+
+        # System-scoped user can see all runbooks
+        _, system_runbooks = self.system_reader_client.list_runbooks()
+        self.assertIn(self.public_runbook['uuid'],
+                      [r['uuid'] for r in system_runbooks['runbooks']])
+        self.assertIn(self.private_runbook['uuid'],
+                      [r['uuid'] for r in system_runbooks['runbooks']])
+
+    @decorators.idempotent_id('903d0027-9265-9f87-c86d-09867aa24edd')
+    def test_runbook_ownership_and_public_flag(self):
+        # Only system-scoped users can set a runbook as public
+
+        patch_public = [{'path': '/public', 'op': 'replace', 'value': True}]
+        self.assertRaises(lib_exc.Forbidden,
+                          self.project_admin_client.update_runbook,
+                          self.private_runbook['uuid'],
+                          patch=patch_public)
+
+        # Setting a runbook as public nullifies its owner field
+        self.system_admin_client.update_runbook(self.private_runbook['uuid'],
+                                                patch=patch_public)
+        _, updated_runbook = self.system_admin_client.show_runbook(
+            self.private_runbook['uuid'])
+        self.assertIsNone(updated_runbook['owner'])
+
+        # Project-scoped user cannot change the owner of a runbook
+        patch_owner = [{'path': '/public', 'op': 'replace', 'value': True}]
+        self.assertRaises(lib_exc.Forbidden,
+                          self.project_admin_client.update_runbook,
+                          self.public_runbook['uuid'],
+                          patch=patch_owner)
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
index e9090cb..ad9e4c9 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
@@ -16,10 +16,12 @@
 
 import time
 
+from oslo_log import log as logging
 from tempest.common import waiters
 from tempest import config
 from tempest.lib.common import api_version_utils
 from tempest.lib.common.utils.linux import remote_client
+from tempest.lib.common.utils import test_utils
 from tempest.lib import exceptions as lib_exc
 
 from ironic_tempest_plugin.common import utils
@@ -27,6 +29,7 @@
 from ironic_tempest_plugin import manager
 
 CONF = config.CONF
+LOG = logging.getLogger(__name__)
 
 
 def retry_on_conflict(func):
@@ -307,3 +310,44 @@
                                        instance['id'], 'ACTIVE')
         # Verify server connection
         self.get_remote_client(server_ip, server=instance)
+
+    def wait_for_ssh(self, ip_address,
+                     username=None,
+                     private_key=None,
+                     server=None,
+                     timeout=60,
+                     delay=10):
+        def _wait_ssh():
+            try:
+                self.get_remote_client(ip_address, username, private_key,
+                                       server=server)
+            except Exception:
+                LOG.debug("Failed to get ssh client for %s", ip_address,
+                          exc_info=True)
+                return False
+            return True
+
+        res = test_utils.call_until_true(_wait_ssh, timeout, delay)
+        self.assertTrue(res, f"Failed to wait for ssh on {ip_address}")
+
+    def check_vm_connectivity(self,
+                              ip_address,
+                              username=None,
+                              private_key=None,
+                              should_connect=True,
+                              extra_msg="",
+                              server=None,
+                              mtu=None):
+        # NOTE(vsaienko): it may take some time to boot VM and initialize
+        # ssh by cloud init. Wait for SSH can pass authentication before
+        # checking connectivity.
+        if should_connect:
+            self.wait_for_ssh(ip_address=ip_address, username=username,
+                              private_key=private_key, server=server)
+        super().check_vm_connectivity(ip_address=ip_address,
+                                      username=username,
+                                      private_key=private_key,
+                                      should_connect=should_connect,
+                                      extra_msg=extra_msg,
+                                      server=server,
+                                      mtu=mtu)
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
index 01289ce..7fa8cb5 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
@@ -343,7 +343,7 @@
         :param image_ref: Reference to user image to boot node with.
         :param image_checksum: md5sum of image specified in image_ref.
                                Needed only when direct HTTP link is provided.
-        :param boot_option: The defaut boot option to utilize. If not
+        :param boot_option: The default boot option to utilize. If not
                             specified, the ironic deployment default shall
                             be utilized.
         :param config_drive_networking: If we should load configuration drive
@@ -459,7 +459,7 @@
             timeout=CONF.baremetal.unrescue_timeout,
             interval=1)
 
-    def manual_cleaning(self, node, clean_steps):
+    def manual_cleaning(self, node, clean_steps=None, runbook=None):
         """Performs manual cleaning.
 
         The following actions are executed:
@@ -470,15 +470,25 @@
 
         :param node: Ironic node to associate instance_uuid with.
         :param clean_steps: clean steps for manual cleaning.
+        :param runbook: unique identifier of a runbook.
         """
+        if clean_steps is None and runbook is None:
+            raise ValueError("Either clean_steps or runbook must be provided.")
+
         self.set_node_provision_state(node['uuid'], 'manage')
         self.wait_provisioning_state(
             node['uuid'],
             [bm.BaremetalProvisionStates.MANAGEABLE],
             timeout=CONF.baremetal.unprovision_timeout,
             interval=30)
-        self.set_node_provision_state(
-            node['uuid'], 'clean', clean_steps=clean_steps)
+
+        if runbook:
+            self.set_node_provision_state(
+                node['uuid'], 'clean', runbook=runbook)
+        else:
+            self.set_node_provision_state(
+                node['uuid'], 'clean', clean_steps=clean_steps)
+
         self.wait_provisioning_state(
             node['uuid'],
             [bm.BaremetalProvisionStates.MANAGEABLE],
@@ -492,6 +502,17 @@
             timeout=CONF.baremetal.unprovision_timeout,
             interval=30)
 
+    def manual_cleaning_with_runbook(self, node):
+        steps = [{
+            'interface': 'bios',
+            'step': 'apply_configuration',
+            'args': {},
+            'order': 1
+        }]
+        _, runbook = self.baremetal_client.create_runbook('CUSTOM_AWESOME',
+                                                          steps=steps)
+        self.manual_cleaning(node, runbook=runbook)
+
     def check_manual_partition_cleaning(self, node):
         """Tests the cleanup step for erasing devices metadata.
 
@@ -562,7 +583,7 @@
     # If we don't require an explicit driver, then what drivers *can* we
     # operate with. In essence, this exists to prevent the test from failing
     # on 3rd party drivers, and vendor specific driers which do not support
-    # the sort of itnerfaces we may be trying to test by default.
+    # the sort of interfaces we may be trying to test by default.
     valid_driver_list = []
 
     # The bios interface to use by the HW type. The bios interface of the
@@ -607,6 +628,12 @@
     # set via a different test).
     power_interface = None
 
+    # The inspect interface to use by the HW type. The inspect interface of the
+    # node used in the test will be set to this value. If set to None, the
+    # node will retain its existing inspect_interface value (which may have
+    # been set via a different test).
+    inspect_interface = None
+
     # Boolean value specify if image is wholedisk or not.
     wholedisk_image = None
 
@@ -632,55 +659,18 @@
                     'driver': cls.driver,
                     'enabled_drivers': CONF.baremetal.enabled_drivers,
                     'enabled_hw_types': CONF.baremetal.enabled_hardware_types})
-        if (cls.bios_interface and cls.bios_interface not in
-                CONF.baremetal.enabled_bios_interfaces):
-            raise cls.skipException(
-                "Bios interface %(iface)s required by the test is not in the "
-                "list of enabled bios interfaces %(enabled)s" % {
-                    'iface': cls.bios_interface,
-                    'enabled': CONF.baremetal.enabled_bios_interfaces})
-        if (cls.deploy_interface and cls.deploy_interface not in
-                CONF.baremetal.enabled_deploy_interfaces):
-            raise cls.skipException(
-                "Deploy interface %(iface)s required by test is not "
-                "in the list of enabled deploy interfaces %(enabled)s" % {
-                    'iface': cls.deploy_interface,
-                    'enabled': CONF.baremetal.enabled_deploy_interfaces})
-        if (cls.rescue_interface and cls.rescue_interface not in
-                CONF.baremetal.enabled_rescue_interfaces):
-            raise cls.skipException(
-                "Rescue interface %(iface)s required by test is not "
-                "in the list of enabled rescue interfaces %(enabled)s" % {
-                    'iface': cls.rescue_interface,
-                    'enabled': CONF.baremetal.enabled_rescue_interfaces})
-        if (cls.boot_interface and cls.boot_interface not in
-                CONF.baremetal.enabled_boot_interfaces):
-            raise cls.skipException(
-                "Boot interface %(iface)s required by test is not "
-                "in the list of enabled boot interfaces %(enabled)s" % {
-                    'iface': cls.boot_interface,
-                    'enabled': CONF.baremetal.enabled_boot_interfaces})
-        if (cls.raid_interface and cls.raid_interface not in
-                CONF.baremetal.enabled_raid_interfaces):
-            raise cls.skipException(
-                "RAID interface %(iface)s required by test is not "
-                "in the list of enabled RAID interfaces %(enabled)s" % {
-                    'iface': cls.raid_interface,
-                    'enabled': CONF.baremetal.enabled_raid_interfaces})
-        if (cls.management_interface and cls.management_interface not in
-                CONF.baremetal.enabled_management_interfaces):
-            raise cls.skipException(
-                "Management interface %(iface)s required by test is not "
-                "in the list of enabled management interfaces %(enabled)s" % {
-                    'iface': cls.management_interface,
-                    'enabled': CONF.baremetal.enabled_management_interfaces})
-        if (cls.power_interface and cls.power_interface not in
-                CONF.baremetal.enabled_power_interfaces):
-            raise cls.skipException(
-                "Power interface %(iface)s required by test is not "
-                "in the list of enabled power interfaces %(enabled)s" % {
-                    'iface': cls.power_interface,
-                    'enabled': CONF.baremetal.enabled_power_interfaces})
+        for iface in base.SUPPORTED_INTERFACES:
+            requested = getattr(cls, f'{iface}_interface')
+            enabled = getattr(CONF.baremetal, f'enabled_{iface}_interfaces')
+            if requested and requested not in enabled:
+                raise cls.skipException(
+                    "%(type)s interface %(iface)s required by the test is not "
+                    "in the list of enabled %(type)s interfaces "
+                    "%(enabled)s" % {
+                        'iface': requested,
+                        'type': iface,
+                        'enabled': ', '.join(enabled),
+                    })
         if (cls.wholedisk_image is not None
                 and not cls.wholedisk_image
                 and CONF.baremetal.use_provision_network):
@@ -720,27 +710,16 @@
         if not uuidutils.is_uuid_like(cls.image_ref):
             image_checksum = cls.image_checksum
         boot_kwargs = {'image_checksum': image_checksum}
-        if cls.bios_interface:
-            boot_kwargs['bios_interface'] = cls.bios_interface
-        if cls.deploy_interface:
-            boot_kwargs['deploy_interface'] = cls.deploy_interface
-        if cls.rescue_interface:
-            boot_kwargs['rescue_interface'] = cls.rescue_interface
-        if cls.boot_interface:
-            boot_kwargs['boot_interface'] = cls.boot_interface
-        if cls.raid_interface:
-            boot_kwargs['raid_interface'] = cls.raid_interface
-        if cls.management_interface:
-            boot_kwargs['management_interface'] = cls.management_interface
-        if cls.power_interface:
-            boot_kwargs['power_interface'] = cls.power_interface
+        for iface in base.SUPPORTED_INTERFACES:
+            if requested := getattr(cls, f'{iface}_interface'):
+                boot_kwargs[f'{iface}_interface'] = requested
 
         # just get an available node
         cls.node = cls.get_and_reserve_node()
         if (cls.use_available_driver
                 and not cls.driver
                 and cls.node['driver'] in cls.valid_driver_list):
-            # If we're attempting to re-use the existing driver, then
+            # If we're attempting to reuse the existing driver, then
             # lets save a value for update_node_driver to work with.
             cls.driver = cls.node['driver']
         cls.update_node_driver(cls.node['uuid'], cls.driver, **boot_kwargs)
diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_adoption.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_adoption.py
index ba56917..d6a5a50 100644
--- a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_adoption.py
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_adoption.py
@@ -34,7 +34,7 @@
         driver = 'ipmi'
     image_ref = CONF.baremetal.whole_disk_image_ref
     wholedisk_image = True
-    deploy_interface = 'iscsi'
+    deploy_interface = 'direct'
     # 1.37 is required to be able to copy traits
     api_microversion = '1.37'
 
diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
index 5901d20..09da6d3 100644
--- a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
@@ -135,6 +135,7 @@
     image_ref = CONF.baremetal.whole_disk_image_ref
     wholedisk_image = True
 
+    @decorators.unstable_test(bug='2101021')
     @decorators.idempotent_id('cde532cc-81ba-4489-b374-b4a85cc203eb')
     @utils.services('image', 'network')
     def test_ip_access_to_server(self):
@@ -638,7 +639,7 @@
     use_available_driver = True
 
     # List of valid drivers which these tests *can* attempt to utilize.
-    # Generally these should be the most commom, stock, upstream drivers.
+    # Generally these should be the most common, stock, upstream drivers.
     valid_driver_list = ['ipmi', 'redfish']
 
     # Bypass secondary attribute presence check as these tests don't require
diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_inspection_basic.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_inspection_basic.py
index 97f0fa3..7d1d602 100644
--- a/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_inspection_basic.py
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_inspection_basic.py
@@ -21,23 +21,16 @@
 CONF = config.CONF
 
 
-class BaremetalIdracInspect(bsm.BaremetalStandaloneScenarioTest):
+class BaremetalInspectBase:
 
-    driver = 'idrac'
     mandatory_attr = ['driver', 'inspect_interface']
-    # The test cases clean up at the end by detaching the VIF.
-    # Support for VIFs was introduced by version 1.28
-    # (# v1.28: Add vifs subcontroller to node).
-    api_microversion = '1.28'
+    # (# v1.31: Support for updating inspect_interface).
+    api_microversion = '1.31'
     delete_node = False
+    wait_provisioning_state_interval = 1
 
-    def _verify_node_inspection_data(self):
-        _, node = self.baremetal_client.show_node(self.node['uuid'])
-
-        self.assertEqual(node['properties']['cpu_arch'], 'x86_64')
-        self.assertGreater(int(node['properties']['memory_mb']), 0)
-        self.assertGreater(int(node['properties']['cpus']), 0)
-        self.assertGreater(int(node['properties']['local_gb']), 0)
+    def _verify_node_inspection_data(self, node):
+        self.assertIn(node['properties']['cpu_arch'], ['x86_64', 'aarch64'])
 
     @decorators.idempotent_id('47ea4487-4720-43e8-a024-53ae82f8c264')
     def test_baremetal_inspect(self):
@@ -50,19 +43,100 @@
         """
         self.baremetal_client.set_node_provision_state(self.node['uuid'],
                                                        'manage')
+        _, node = self.baremetal_client.show_node(self.node['uuid'])
+        if 'cpu_arch' in node['properties']:
+            new_properties = node['properties'].copy()
+            new_properties.pop('cpu_arch')
+            self.baremetal_client.update_node(self.node['uuid'],
+                                              properties=new_properties)
+
         self.baremetal_client.set_node_provision_state(self.node['uuid'],
                                                        'inspect')
+        self.wait_provisioning_state(
+            self.node['uuid'], 'manageable',
+            timeout=CONF.baremetal.inspect_timeout,
+            interval=self.wait_provisioning_state_interval)
 
-        self.wait_provisioning_state(self.node['uuid'], 'manageable',
-                                     timeout=CONF.baremetal.inspect_timeout)
-
-        self._verify_node_inspection_data()
+        _, node = self.baremetal_client.show_node(self.node['uuid'])
+        self._verify_node_inspection_data(node)
 
         self.baremetal_client.set_node_provision_state(self.node['uuid'],
                                                        'provide')
         self.wait_provisioning_state(self.node['uuid'], 'available')
 
 
+class BaremetalRedfishAgentInspect(BaremetalInspectBase,
+                                   bsm.BaremetalStandaloneScenarioTest):
+    driver = 'redfish'
+    inspect_interface = 'agent'
+    wait_provisioning_state_interval = 15
+    # 1.81 adds support for inventory API
+    api_microversion = '1.81'
+
+    def _verify_node_inspection_data(self, node):
+        super()._verify_node_inspection_data(node)
+        inspection_data = self.baremetal_client.show_inventory(
+            self.node['uuid'])
+        self.assertEqual({'inventory', 'plugin_data'}, set(inspection_data))
+
+        # Inventory sanity check
+        inventory = inspection_data['inventory']
+        self.assertGreater(inventory['cpu']['count'], 0)
+        self.assertGreater(inventory['memory']['physical_mb'], 256)
+        self.assertGreater(len(inventory['disks']), 0)
+        self.assertGreater(len(inventory['interfaces']), 0)
+
+    @decorators.idempotent_id('82670a85-49b7-4d28-b8a8-6e18b0d4c8d1')
+    def test_inspect_abort(self):
+        _, node = self.baremetal_client.show_node(self.node['uuid'])
+        current_state = node['provision_state']
+
+        def cleanup():
+            nonlocal current_state
+
+            if current_state == 'inspect failed':
+                self.baremetal_client.set_node_provision_state(
+                    self.node['uuid'], 'manage')
+                self.wait_provisioning_state(self.node['uuid'], 'manageable')
+                current_state = 'manageable'
+
+            if current_state == 'manageable':
+                self.baremetal_client.set_node_provision_state(
+                    self.node['uuid'], 'provide')
+                self.wait_provisioning_state(self.node['uuid'], 'available')
+
+        self.addCleanup(cleanup)
+
+        if current_state != 'manageable':
+            self.baremetal_client.set_node_provision_state(self.node['uuid'],
+                                                           'manage')
+        current_state = 'manageable'
+
+        self.baremetal_client.set_node_provision_state(self.node['uuid'],
+                                                       'inspect')
+        self.wait_provisioning_state(
+            self.node['uuid'], 'inspect wait',
+            timeout=300, interval=5)
+
+        self.baremetal_client.set_node_provision_state(self.node['uuid'],
+                                                       'abort')
+        self.wait_provisioning_state(
+            self.node['uuid'], 'inspect failed',
+            timeout=60, interval=1)
+        current_state = 'inspect failed'
+
+
+class BaremetalIdracInspect(BaremetalInspectBase,
+                            bsm.BaremetalStandaloneScenarioTest):
+    driver = 'idrac'
+
+    def _verify_node_inspection_data(self, node):
+        super()._verify_node_inspection_data(node)
+        self.assertGreater(int(node['properties']['memory_mb']), 0)
+        self.assertGreater(int(node['properties']['cpus']), 0)
+        self.assertGreater(int(node['properties']['local_gb']), 0)
+
+
 class BaremetalIdracRedfishInspect(BaremetalIdracInspect):
     inspect_interface = 'idrac-redfish'
 
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
index e8e5656..a0bcfc3 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
@@ -227,6 +227,9 @@
         self.validate_scheduling()
         self.validate_lessee()
         ip_address = self.get_server_ip(self.instance)
+        self.check_vm_connectivity(ip_address=ip_address,
+                                   private_key=self.keypair['private_key'],
+                                   server=self.instance)
         vm_client = self.get_remote_client(ip_address, server=self.instance)
 
         # We expect the ephemeral partition to be mounted on /mnt and to have
@@ -247,12 +250,6 @@
             self.rescue_instance(self.instance, self.node, ip_address)
             self.unrescue_instance(self.instance, self.node, ip_address)
 
-        # Reboot node
-        self.reboot_node(self.instance)
-
-        # ensure we can ping the node again
-        self.assertTrue(self.ping_ip_address(ip_address))
-
         self.terminate_instance(self.instance)
 
     @decorators.idempotent_id('549173a5-38ec-42bb-b0e2-c8b9f4a08943')
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
index 086bf21..b347431 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
@@ -13,14 +13,17 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+from oslo_log import log as logging
 from tempest.common import utils
 from tempest import config
 from tempest.lib.common.utils import data_utils
+from tempest.lib.common.utils import test_utils
 from tempest.lib import decorators
 
 from ironic_tempest_plugin import manager
 from ironic_tempest_plugin.tests.scenario import baremetal_manager
 
+LOG = logging.getLogger(__name__)
 CONF = config.CONF
 
 
@@ -50,13 +53,16 @@
                    'a minimum of 2') % CONF.baremetal.available_nodes
             raise cls.skipException(msg)
 
-    def create_tenant_network(self, clients, tenant_cidr):
+    def create_tenant_network(self, clients, tenant_cidr, create_router=True):
         network = self.create_network(
             networks_client=clients.networks_client,
             project_id=clients.credentials.project_id)
-        router = self.get_router(
-            client=clients.routers_client,
-            project_id=clients.credentials.tenant_id)
+
+        router = None
+        if create_router:
+            router = self.get_router(
+                client=clients.routers_client,
+                project_id=clients.credentials.tenant_id)
 
         result = clients.subnets_client.create_subnet(
             name=data_utils.rand_name('subnet'),
@@ -65,25 +71,39 @@
             ip_version=4,
             cidr=tenant_cidr)
         subnet = result['subnet']
-        clients.routers_client.add_router_interface(router['id'],
-                                                    subnet_id=subnet['id'])
+        if create_router:
+            clients.routers_client.add_router_interface(router['id'],
+                                                        subnet_id=subnet['id'])
         self.addCleanup(clients.subnets_client.delete_subnet, subnet['id'])
-        self.addCleanup(clients.routers_client.remove_router_interface,
-                        router['id'], subnet_id=subnet['id'])
+
+        if create_router:
+            self.addCleanup(clients.routers_client.remove_router_interface,
+                            router['id'], subnet_id=subnet['id'])
         return network, subnet, router
 
     def verify_l3_connectivity(self, source_ip, private_key,
-                               destination_ip, conn_expected=True):
+                               destination_ip, conn_expected=True, timeout=15):
         remote = self.get_remote_client(source_ip, private_key=private_key)
         remote.validate_authentication()
 
+        output = remote.exec_command('ip route')
+        LOG.debug("Routing table on %s is %s", source_ip, output)
+
         cmd = 'ping %s -c4 -w4 || exit 0' % destination_ip
         success_substring = " bytes from %s" % destination_ip
-        output = remote.exec_command(cmd)
-        if conn_expected:
-            self.assertIn(success_substring, output)
-        else:
-            self.assertNotIn(success_substring, output)
+
+        def ping_remote():
+            output = remote.exec_command(cmd)
+            LOG.debug("Got output %s while pinging %s", output, destination_ip)
+            if conn_expected:
+                return success_substring in output
+            else:
+                return success_substring not in output
+
+        # NOTE(vsaienko): we may lost couple of pings due to missing ARPs
+        # so do several retries to get stable output.
+        res = test_utils.call_until_true(ping_remote, timeout, 1)
+        self.assertTrue(res)
 
     def multitenancy_check(self, use_vm=False):
         tenant_cidr = '10.0.100.0/24'
@@ -165,3 +185,137 @@
             self.skipTest('Compute service Nova is disabled,'
                           ' VM is required to run this test')
         self.multitenancy_check(use_vm=True)
+
+    @decorators.idempotent_id('6891929f-a254-43b1-bd97-6ea3ec74d6a9')
+    @utils.services('compute', 'image', 'network')
+    def test_baremetal_vm_multitenancy_trunk(self):
+        """Check Trunk scenario for two baremetal servers
+
+
+        fipA -- RouterA -- NetworkA (10.0.100.0/24)
+                              |
+                              |eth0
+                             eth0:instanceA
+                              |eth0.vlan_id
+                              |
+                           NetworkB(10.0.101.0/24)
+                              |
+                              |eth0
+                           instanceB
+
+
+        * Create instanceA within networkA and FIPA with trunk port plugged to
+          networkA as parent(native vlan/untagged) and networkB as
+          vlan subport
+        * Create instanceB within networkB
+        * Verify connectivity to instanceB from instanceA failed
+        * Assign ip address on subport inside instanceA. This step is needed
+          only unless nova configdrive support of trunks is not implemented.
+        * Verify connectivity to instanceB from instanceA success
+        * Remove subport from instanceA
+        * Verify connectivity to instanceB from instanceA failed
+        * Add subport to instanceA
+        * Verify connectivity to instanceB from instanceA Passed
+        """
+
+        if not CONF.baremetal_feature_enabled.trunks_supported:
+            msg = 'Trunks with baremetal are not supported.'
+            raise self.skipException(msg)
+
+        tenant_a_cidr = '10.0.100.0/24'
+        tenant_b_cidr = '10.0.101.0/24'
+
+        keypair = self.create_keypair()
+        networkA, subnetA, routerA = self.create_tenant_network(
+            self.os_primary, tenant_a_cidr)
+        networkB, subnetB, _ = self.create_tenant_network(
+            self.os_primary, tenant_b_cidr, create_router=False)
+        portB = self.create_port(network_id=networkB["id"])
+
+        parent_port = self.create_port(network_id=networkA["id"])
+        subport = self.create_port(network_id=networkB["id"])
+        subports = [{'port_id': subport['id'], 'segmentation_type': 'inherit'}]
+        trunk = self.os_primary.trunks_client.create_trunk(
+            name="test-trunk", port_id=parent_port['id'],
+            sub_ports=subports)['trunk']
+        self.addCleanup(self.os_primary.trunks_client.delete_trunk,
+                        trunk['id'])
+
+        # Create instanceB first as we will not check if its booted,
+        # as we don't have FIP, so it has more time than instanceA
+        # to boot.
+        instanceB, nodeB = self.boot_instance(
+            clients=self.os_primary,
+            keypair=keypair,
+            networks=[{'port': portB['id']}]
+        )
+
+        instanceA, nodeA = self.boot_instance(
+            clients=self.os_primary,
+            keypair=keypair,
+            networks=[{'port': parent_port['id']}]
+        )
+
+        floating_ipA = self.create_floating_ip(
+            instanceA,
+        )['floating_ip_address']
+
+        fixed_ipB = instanceB['addresses'][networkB['name']][0]['addr']
+
+        self.check_vm_connectivity(ip_address=floating_ipA,
+                                   private_key=keypair['private_key'],
+                                   server=instanceA)
+        ssh_client = self.get_remote_client(floating_ipA,
+                                            private_key=keypair['private_key'])
+
+        # TODO(vsaienko): add when cloudinit support is implemented
+        # add validation of network_data.json and drop next ip assignment
+
+        self.verify_l3_connectivity(
+            floating_ipA,
+            keypair['private_key'],
+            fixed_ipB,
+            conn_expected=False
+        )
+        vlan_id = trunk['sub_ports'][0]['segmentation_id']
+        subport_ip = subport['fixed_ips'][0]['ip_address']
+
+        interface_name = ssh_client.exec_command(
+            "sudo ip route | awk '/default/ {print $5}'").rstrip()
+        cmds = [
+            f"sudo ip link add link {interface_name} name "
+            f"{interface_name}.{vlan_id} type vlan id {vlan_id}",
+            f"sudo ip addr add {subport_ip}/24 dev {interface_name}.{vlan_id}",
+            f"sudo ip link set dev {interface_name}.{vlan_id} up"]
+
+        for cmd in cmds:
+            ssh_client.exec_command(cmd)
+
+        self.verify_l3_connectivity(
+            floating_ipA,
+            keypair['private_key'],
+            fixed_ipB,
+            conn_expected=True
+        )
+
+        self.os_primary.trunks_client.delete_subports_from_trunk(
+            trunk['id'], trunk['sub_ports'])
+        self.verify_l3_connectivity(
+            floating_ipA,
+            keypair['private_key'],
+            fixed_ipB,
+            conn_expected=False
+        )
+        self.os_primary.trunks_client.add_subports_to_trunk(
+            trunk['id'], trunk['sub_ports'])
+
+        # NOTE(vsaienko): it may take some time for network driver to
+        # setup vlans as this is async operation.
+        self.verify_l3_connectivity(
+            floating_ipA,
+            keypair['private_key'],
+            fixed_ipB,
+            conn_expected=True
+        )
+        self.terminate_instance(instance=instanceA)
+        self.terminate_instance(instance=instanceB)
diff --git a/ironic_tempest_plugin/tests/scenario/test_introspection_basic.py b/ironic_tempest_plugin/tests/scenario/test_introspection_basic.py
index f30b5e0..44b8f98 100644
--- a/ironic_tempest_plugin/tests/scenario/test_introspection_basic.py
+++ b/ironic_tempest_plugin/tests/scenario/test_introspection_basic.py
@@ -61,9 +61,6 @@
             * Verifies all properties are inspected
             * Verifies introspection data
             * Sets node to available state
-            * Creates a keypair
-            * Boots an instance using the keypair
-            * Deletes the instance
 
         """
         # prepare introspection rule
@@ -103,11 +100,6 @@
                 timeout=CONF.baremetal.active_timeout,
                 interval=self.wait_provisioning_state_interval)
 
-        self.wait_for_nova_aware_of_bvms()
-        self.add_keypair()
-        ins, _node = self.boot_instance()
-        self.terminate_instance(ins)
-
     @decorators.idempotent_id('70ca3070-184b-4b7d-8892-e977d2bc2870')
     def test_introspection_abort(self):
         """This smoke test case follows this very basic set of operations:
diff --git a/ironic_tempest_plugin/tests/scenario/test_introspection_discovery.py b/ironic_tempest_plugin/tests/scenario/test_introspection_discovery.py
index 6ebfcc6..9950453 100644
--- a/ironic_tempest_plugin/tests/scenario/test_introspection_discovery.py
+++ b/ironic_tempest_plugin/tests/scenario/test_introspection_discovery.py
@@ -136,13 +136,13 @@
            * Generate discovery rule;
            * Start introspection via ironic-inspector API;
            * Delete the node from ironic;
-           * Wating for node discovery;
+           * Waiting for node discovery;
            * Verify introspected node.
         """
         # NOTE(aarefiev): workaround for infra, 'tempest' user doesn't
         # have virsh privileges, so lets power on the node via ironic
         # and then delete it. Because of node is blacklisted in inspector
-        # we can't just power on it, therefor start introspection is used
+        # we can't just power on it, therefore start introspection is used
         # to whitelist discovered node first.
         self.baremetal_client.set_node_provision_state(
             self.node_info['uuid'], 'manage')
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..4fa5ffd
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,18 @@
+[build-system]
+requires = ["pbr>=6.0.0", "setuptools>=64.0.0"]
+build-backend = "pbr.build"
+
+[tool.doc8]
+ignore = ["D001"]
+
+[tool.ruff]
+line-length = 79
+target-version = "py37"
+
+[tool.ruff.lint]
+select = [
+    "E",        # pycodestyle (error)
+    "F",        # pyflakes
+    "G",        # flake8-logging-format
+    "LOG",      # flake8-logging
+]
diff --git a/releasenotes/notes/drop-python38-support-8bfac4d85b4b19e2.yaml b/releasenotes/notes/drop-python38-support-8bfac4d85b4b19e2.yaml
new file mode 100644
index 0000000..f0ada81
--- /dev/null
+++ b/releasenotes/notes/drop-python38-support-8bfac4d85b4b19e2.yaml
@@ -0,0 +1,6 @@
+---
+upgrade:
+  - |
+    Support for Python 3.8 has been dropped. Latest release of
+    ironic-tempest-plugin to support python 3.8 is 2.11.0.
+    The minimum version of Python now supported is Python 3.9.
diff --git a/requirements.txt b/requirements.txt
index bfafa58..ff7b704 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-pbr>=2.0.0 # Apache-2.0
+pbr>=6.0.0 # Apache-2.0
 oslo.config>=5.2.0 # Apache-2.0
 oslo.log>=3.36.0 # Apache-2.0
 oslo.serialization>=2.18.0 # Apache-2.0
diff --git a/setup.cfg b/setup.cfg
index a5cf426..c1cb2cf 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,6 +6,7 @@
 author = OpenStack
 author_email = openstack-discuss@lists.openstack.org
 home_page = https://docs.openstack.org/ironic-tempest-plugin/latest/
+python_requires = >=3.9
 classifier =
     Environment :: OpenStack
     Intended Audience :: Information Technology
@@ -14,10 +15,10 @@
     Operating System :: POSIX :: Linux
     Programming Language :: Python
     Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.6
-    Programming Language :: Python :: 3.7
-    Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: 3.12
 
 [files]
 packages =
diff --git a/setup.py b/setup.py
index cd35c3c..b997e51 100644
--- a/setup.py
+++ b/setup.py
@@ -16,5 +16,5 @@
 import setuptools
 
 setuptools.setup(
-    setup_requires=['pbr>=2.0.0'],
+    setup_requires=['pbr>=6.0.0'],
     pbr=True)
diff --git a/tox.ini b/tox.ini
index 3a67abc..bf8061c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-minversion = 3.18.0
+minversion = 4.4.0
 envlist = pep8
 ignore_basepython_conflict=true
 
@@ -14,11 +14,9 @@
 commands = stestr run --slowest {posargs}
 
 [testenv:pep8]
-deps =
-    hacking~=6.0.0 # Apache-2.0
-    flake8-import-order>=0.17.1 # LGPLv3
-    pycodestyle>=2.0.0,<3.0.0 # MIT
-commands = flake8 {posargs}
+deps = pre-commit
+allowlist_externals = pre-commit
+commands = pre-commit run --all-files --show-diff-on-failure {posargs}
 
 [testenv:venv]
 commands = {posargs}
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index 11eb7c0..4d9da27 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -7,69 +7,63 @@
       jobs:
         # NOTE(dtantsur): keep N-3 and older non-voting for these jobs.
         - ironic-standalone
+        - ironic-standalone-2024.2
         - ironic-standalone-2024.1
-        - ironic-standalone-2023.2
-        - ironic-standalone-2023.1:
+        - ironic-standalone-2023.2:
             voting: false
         - ironic-tempest-functional-python3
+        - ironic-tempest-functional-python3-2024.2
         - ironic-tempest-functional-python3-2024.1
         - ironic-tempest-functional-python3-2023.2:
             voting: false
-        - ironic-tempest-functional-rbac-scope-enforced
+        - ironic-tempest-functional-rbac-scope-enforced-2024.2
         - ironic-tempest-functional-rbac-scope-enforced-2024.1
-        - ironic-tempest-functional-rbac-scope-enforced-2023.2
-        # Enable these *once* we have the policy fix backported
-        # for making own node changes.
-        # - ironic-tempest-functional-rbac-scope-enforced-2023.1
-        - ironic-inspector-tempest
-        - ironic-inspector-tempest-2024.1
-        - ironic-inspector-tempest-2023.2
-        - ironic-inspector-tempest-2023.1:
+        - ironic-tempest-functional-rbac-scope-enforced-2023.2:
             voting: false
         - ironic-standalone-anaconda
+        - ironic-standalone-anaconda-2024.2
         - ironic-standalone-anaconda-2024.1
-        - ironic-standalone-anaconda-2023.2
-        - ironic-standalone-anaconda-2023.1:
+        - ironic-standalone-anaconda-2023.2:
             voting: false
         - ironic-standalone-redfish
+        - ironic-standalone-redfish-2024.2
         - ironic-standalone-redfish-2024.1
-        - ironic-standalone-redfish-2023.2
-        - ironic-standalone-redfish-2023.1:
+        - ironic-standalone-redfish-2023.2:
+            voting: false
+        # NOTE(dtantsur): inspector is deprecated and rarely sees any changes,
+        # no point in running many jobs
+        - ironic-inspector-tempest
+        - ironic-inspector-tempest-2024.2:
+            voting: false
+        - ironic-inspector-tempest-2024.1:
             voting: false
         # NOTE(dtantsur): these jobs cover rarely changed tests and are quite
         # unstable, so keep them non-voting.
         # NOTE(TheJulia): Except this first one so we can validate fixes to
         # the base tests as we make them.
         - ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
+        - ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2024.2:
+            voting: false
         - ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2024.1:
             voting: false
         - ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2023.2:
             voting: false
-        - ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2023.1:
-            voting: false
-        - ironic-inspector-tempest-discovery
-        - ironic-inspector-tempest-discovery-2024.1
-        - ironic-inspector-tempest-discovery-2023.2
-        - ironic-inspector-tempest-discovery-2023.1:
+        - ironic-inspector-tempest-discovery:
             voting: false
     gate:
       jobs:
         - ironic-standalone
+        - ironic-standalone-2024.2
         - ironic-standalone-2024.1
-        - ironic-standalone-2023.2
         - ironic-tempest-functional-python3
+        - ironic-tempest-functional-python3-2024.2
         - ironic-tempest-functional-python3-2024.1
-        - ironic-tempest-functional-python3-2023.2
+        - ironic-tempest-functional-rbac-scope-enforced-2024.2
         - ironic-tempest-functional-rbac-scope-enforced-2024.1
-        - ironic-tempest-functional-rbac-scope-enforced-2023.2
-        - ironic-tempest-functional-rbac-scope-enforced
-        - ironic-inspector-tempest
-        - ironic-inspector-tempest-2024.1
-        - ironic-inspector-tempest-2023.2
-        - ironic-inspector-tempest-rbac-scope-enforced
-        - ironic-inspector-tempest-rbac-scope-enforced-2024.1
-        - ironic-inspector-tempest-rbac-scope-enforced-2023.2
+        - ironic-standalone-anaconda
+        - ironic-standalone-anaconda-2024.2
+        - ironic-standalone-anaconda-2024.1
         - ironic-standalone-redfish
+        - ironic-standalone-redfish-2024.2
         - ironic-standalone-redfish-2024.1
-        - ironic-standalone-redfish-2023.2
-        - ironic-inspector-tempest-discovery
+        - ironic-inspector-tempest
diff --git a/zuul.d/stable-jobs.yaml b/zuul.d/stable-jobs.yaml
index 3643bf4..c75f782 100644
--- a/zuul.d/stable-jobs.yaml
+++ b/zuul.d/stable-jobs.yaml
@@ -1,4 +1,9 @@
 - job:
+    name: ironic-standalone-2024.2
+    parent: ironic-standalone
+    override-checkout: stable/2024.2
+
+- job:
     name: ironic-standalone-2024.1
     parent: ironic-standalone
     override-checkout: stable/2024.1
@@ -9,47 +14,9 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-standalone-2023.1
+    name: ironic-standalone-redfish-2024.2
     parent: ironic-standalone
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-standalone-zed
-    parent: ironic-standalone
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-standalone-yoga
-    parent: ironic-standalone
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-standalone-xena
-    parent: ironic-standalone
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-standalone-wallaby
-    parent: ironic-standalone
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-standalone-victoria
-    parent: ironic-standalone
-    override-checkout: stable/victoria
-
-- job:
-    name: ironic-standalone-ussuri
-    parent: ironic-standalone
-    override-checkout: stable/ussuri
-
-- job:
-    name: ironic-standalone-train
-    parent: ironic-standalone
-    override-checkout: stable/train
-    vars:
-      devstack_localrc:
-        USE_PYTHON3: True
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-standalone-redfish-2024.1
@@ -62,51 +29,9 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-standalone-redfish-2023.1
-    parent: ironic-standalone
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-standalone-redfish-zed
-    parent: ironic-standalone
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-standalone-redfish-yoga
-    parent: ironic-standalone-redfish
-    nodeset: openstack-single-node-focal
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-standalone-redfish-xena
-    parent: ironic-standalone-redfish
-    nodeset: openstack-single-node-focal
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-standalone-redfish-wallaby
-    parent: ironic-standalone-redfish
-    nodeset: openstack-single-node-focal
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-standalone-redfish-victoria
-    parent: ironic-standalone-redfish
-    nodeset: openstack-single-node-focal
-    override-checkout: stable/victoria
-
-- job:
-    name: ironic-standalone-redfish-ussuri
-    parent: ironic-standalone-redfish
-    override-checkout: stable/ussuri
-
-- job:
-    name: ironic-standalone-redfish-train
-    parent: ironic-standalone-redfish
-    override-checkout: stable/train
-    vars:
-      devstack_localrc:
-        USE_PYTHON3: True
+    name: ironic-standalone-anaconda-2024.2
+    parent: ironic-standalone-anaconda
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-standalone-anaconda-2024.1
@@ -119,9 +44,9 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-standalone-anaconda-2023.1
-    parent: ironic-standalone-anaconda
-    override-checkout: stable/2023.1
+    name: ironic-tempest-functional-python3-2024.2
+    parent: ironic-tempest-functional-python3
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-tempest-functional-python3-2024.1
@@ -134,44 +59,9 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-tempest-functional-python3-2023.1
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-tempest-functional-python3-zed
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-tempest-functional-python3-yoga
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-tempest-functional-python3-xena
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-tempest-functional-python3-wallaby
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-tempest-functional-python3-victoria
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/victoria
-
-- job:
-    name: ironic-tempest-functional-python3-ussuri
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/ussuri
-
-- job:
-    name: ironic-tempest-functional-python3-train
-    parent: ironic-tempest-functional-python3
-    override-checkout: stable/train
+    name: ironic-tempest-functional-rbac-scope-enforced-2024.2
+    parent: ironic-tempest-functional-rbac-scope-enforced
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-tempest-functional-rbac-scope-enforced-2024.1
@@ -184,19 +74,9 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-tempest-functional-rbac-scope-enforced-2023.1
-    parent: ironic-tempest-functional-rbac-scope-enforced
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-tempest-functional-rbac-scope-enforced-zed
-    parent: ironic-tempest-functional-rbac-scope-enforced
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-zed
+    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2024.2
     parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/zed
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2024.1
@@ -209,174 +89,11 @@
     override-checkout: stable/2023.2
 
 - job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-2023.1
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-yoga
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-xena
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-wallaby
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-victoria
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/victoria
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-ussuri
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/ussuri
-
-- job:
-    name: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode-train
-    parent: ironic-tempest-ipa-wholedisk-direct-tinyipa-multinode
-    override-checkout: stable/train
-    vars:
-      devstack_localrc:
-        USE_PYTHON3: True
+    name: ironic-inspector-tempest-2024.2
+    parent: ironic-inspector-tempest
+    override-checkout: stable/2024.2
 
 - job:
     name: ironic-inspector-tempest-2024.1
     parent: ironic-inspector-tempest
     override-checkout: stable/2024.1
-
-- job:
-    name: ironic-inspector-tempest-2023.2
-    parent: ironic-inspector-tempest
-    override-checkout: stable/2023.2
-
-- job:
-    name: ironic-inspector-tempest-2023.1
-    parent: ironic-inspector-tempest
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-inspector-tempest-zed
-    parent: ironic-inspector-tempest
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-inspector-tempest-yoga
-    parent: ironic-inspector-tempest
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-inspector-tempest-xena
-    parent: ironic-inspector-tempest
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-inspector-tempest-wallaby
-    parent: ironic-inspector-tempest
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-inspector-tempest-victoria
-    parent: ironic-inspector-tempest
-    override-checkout: stable/victoria
-    vars:
-      devstack_localrc:
-        FIXED_NETWORK_SIZE: 4096
-
-- job:
-    name: ironic-inspector-tempest-ussuri
-    parent: ironic-inspector-tempest
-    override-checkout: stable/ussuri
-    vars:
-      devstack_localrc:
-        FIXED_NETWORK_SIZE: 4096
-        EBTABLES_RACE_FIX: True
-
-- job:
-    name: ironic-inspector-tempest-train
-    parent: ironic-inspector-tempest
-    override-checkout: stable/train
-    vars:
-      devstack_localrc:
-        FIXED_NETWORK_SIZE: 4096
-        EBTABLES_RACE_FIX: True
-        USE_PYTHON3: True
-
-- job:
-    name: ironic-inspector-tempest-rbac-scope-enforced-2024.1
-    parent: ironic-inspector-tempest-rbac-scope-enforced
-    override-checkout: stable/2024.1
-
-- job:
-    name: ironic-inspector-tempest-rbac-scope-enforced-2023.2
-    parent: ironic-inspector-tempest-rbac-scope-enforced
-    override-checkout: stable/2023.2
-
-- job:
-    name: ironic-inspector-tempest-rbac-scope-enforced-2023.1
-    parent: ironic-inspector-tempest-rbac-scope-enforced
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-inspector-tempest-rbac-scope-enforced-zed
-    parent: ironic-inspector-tempest-rbac-scope-enforced
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-inspector-tempest-discovery-2024.1
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/2024.1
-
-- job:
-    name: ironic-inspector-tempest-discovery-2023.2
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/2023.2
-
-- job:
-    name: ironic-inspector-tempest-discovery-2023.1
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/2023.1
-
-- job:
-    name: ironic-inspector-tempest-discovery-zed
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/zed
-
-- job:
-    name: ironic-inspector-tempest-discovery-yoga
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/yoga
-
-- job:
-    name: ironic-inspector-tempest-discovery-xena
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/xena
-
-- job:
-    name: ironic-inspector-tempest-discovery-wallaby
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/wallaby
-
-- job:
-    name: ironic-inspector-tempest-discovery-victoria
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/victoria
-
-- job:
-    name: ironic-inspector-tempest-discovery-ussuri
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/ussuri
-
-- job:
-    name: ironic-inspector-tempest-discovery-train
-    parent: ironic-inspector-tempest-discovery
-    override-checkout: stable/train
-    vars:
-      devstack_localrc:
-        USE_PYTHON3: True