Merge "Rework the ironic-inspector auto-discovery test"
diff --git a/doc/source/config/with-nova.rst b/doc/source/config/with-nova.rst
new file mode 100644
index 0000000..3537588
--- /dev/null
+++ b/doc/source/config/with-nova.rst
@@ -0,0 +1,76 @@
+Full cloud with the Compute service and flat networking
+=======================================================
+
+This section documents running tempest on a full OpenStack cloud with the
+Compute, Image and Networking services enabled. The Bare Metal Introspection
+service (ironic-inspector) is not enabled. Flat networking is used.
+
+Prerequisite
+------------
+
+* `Create a bare metal flavor`_ in the Compute service in advance
+  and record its ID (``<flavor uuid>`` below).
+
+* `Create an image`_ to use for instances and record its ID (``<image uuid>``).
+  It can be either a whole disk or a partition image.
+
+* Create and record the name or UUID of a flat network to use for bare metal
+  instances (``<network name>``).
+
+* Get the minimum and maximum API versions that you want to test against.
+  Check the `API version history`_ to find the appropriate versions for
+  your deployment.
+
+  .. note:: The minimum version can usually be set to ``1.1``.
+
+* Enroll_ at least one node and make it ``available``.
+
+.. _Create a bare metal flavor: https://docs.openstack.org/ironic/latest/install/configure-nova-flavors.html
+.. _Create an image: https://docs.openstack.org/ironic/latest/install/configure-glance-images.html
+.. _API version history: https://docs.openstack.org/ironic/latest/contributor/webapi-version-history.html
+.. _Enroll: https://docs.openstack.org/ironic/latest/install/enrollment.html
+
+Configuration
+-------------
+
+.. code-block:: ini
+
+    [service_available]
+    # Enable ironic tests.
+    ironic = True
+
+    # Disable ironic-inspector tests.
+    ironic-inspector = False
+
+    [baremetal]
+    # Minimum and maximum API versions to test against.
+    min_microversion = <min API version as X.Y>
+    max_microversion = <max API version as X.Y>
+
+    [compute]
+    # Configure the bare metal flavor so that the Compute services provisions
+    # bare metal instances during the tests.
+    flavor_ref = <flavor uuid>
+    flavor_ref_alt = <flavor uuid>
+
+    # Configure the image to use.
+    image_ref = <image uuid>
+    image_ref_alt = <image uuid>
+
+    # Configure the network to use.
+    fixed_network_name = <network name>
+
+    [compute-feature-enabled]
+    # Ironic does not support this feature.
+    disk_config = False
+
+    # Not supported with flat networking.
+    interface_attach = False
+
+    [auth]
+    # Not supported with flat networking.
+    create_isolated_networks = False
+
+    [network]
+    # Required for flat networking.
+    shared_physical_network = True
diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index 8ab88eb..3e3c56e 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -19,7 +19,14 @@
     [service_enabled]
     ironic_inspector = True
 
-.. TODO(dtantsur): I'm pretty sure more configuration is required, fill it in
+See the following example configurations for more details:
+
+.. toctree::
+   :maxdepth: 1
+
+   config/with-nova
+
+.. TODO(dtantsur): cover standalone tests
 
 .. _Tempest configuration: https://docs.openstack.org/tempest/latest/configuration.html
 
diff --git a/ironic_tempest_plugin/common/utils.py b/ironic_tempest_plugin/common/utils.py
index 67c4922..91f2001 100644
--- a/ironic_tempest_plugin/common/utils.py
+++ b/ironic_tempest_plugin/common/utils.py
@@ -11,7 +11,7 @@
 #    under the License.
 
 
-def get_node(client, node_id=None, instance_uuid=None):
+def get_node(client, node_id=None, instance_uuid=None, api_version=None):
     """Get a node by its identifier or instance UUID.
 
     If both node_id and instance_uuid specified, node_id will be used.
@@ -19,15 +19,17 @@
     :param client: an instance of tempest plugin BaremetalClient.
     :param node_id: identifier (UUID or name) of the node.
     :param instance_uuid: UUID of the instance.
+    :param api_version: Ironic API version to use.
     :returns: the requested node.
     :raises: AssertionError, if neither node_id nor instance_uuid was provided
     """
     assert node_id or instance_uuid, ('Either node or instance identifier '
                                       'has to be provided.')
     if node_id:
-        _, body = client.show_node(node_id)
+        _, body = client.show_node(node_id, api_version=api_version)
         return body
     elif instance_uuid:
-        _, body = client.show_node_by_instance_uuid(instance_uuid)
+        _, body = client.show_node_by_instance_uuid(instance_uuid,
+                                                    api_version=api_version)
         if body['nodes']:
             return body['nodes'][0]
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index e5ba81e..dc99e03 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -80,6 +80,12 @@
                help="Timeout for unprovisioning an Ironic node. "
                     "Takes longer since Kilo as Ironic performs an extra "
                     "step in Node cleaning."),
+    cfg.IntOpt('rescue_timeout',
+               default=300,
+               help="Timeout for rescuing an Ironic node."),
+    cfg.IntOpt('unrescue_timeout',
+               default=300,
+               help="Timeout for unrescuing an Ironic node."),
     cfg.StrOpt('min_microversion',
                help="Lower version of the test target microversion range. "
                     "The format is 'X.Y', where 'X' and 'Y' are int values. "
@@ -116,10 +122,16 @@
     cfg.ListOpt('enabled_deploy_interfaces',
                 default=['iscsi', 'direct'],
                 help="List of Ironic enabled deploy interfaces."),
+    cfg.ListOpt('enabled_rescue_interfaces',
+                default=['no-rescue'],
+                help="List of Ironic enabled rescue interfaces."),
     cfg.IntOpt('adjusted_root_disk_size_gb',
                min=0,
                help="Ironic adjusted disk size to use in the standalone tests "
                     "as instance_info/root_gb value."),
+    cfg.IntOpt('available_nodes', min=0, default=None,
+               help="The number of baremetal hosts available to use for "
+                    "the tests.")
 ]
 
 BaremetalFeaturesGroup = [
diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py
index 494c780..c588a70 100644
--- a/ironic_tempest_plugin/services/baremetal/base.py
+++ b/ironic_tempest_plugin/services/baremetal/base.py
@@ -154,10 +154,14 @@
                       resource,
                       uuid=None,
                       permanent=False,
+                      headers=None,
+                      extra_headers=False,
                       **kwargs):
         """Gets a specific object of the specified type.
 
         :param uuid: Unique identifier of the object in UUID format.
+        :param headers: List of headers to use in request.
+        :param extra_headers: Specify whether to use headers.
         :returns: Serialized object as a dictionary.
 
         """
@@ -165,7 +169,8 @@
             uri = kwargs['uri']
         else:
             uri = self._get_uri(resource, uuid=uuid, permanent=permanent)
-        resp, body = self.get(uri)
+        resp, body = self.get(uri, headers=headers,
+                              extra_headers=extra_headers)
         self.expected_success(http_client.OK, resp.status)
 
         return resp, self.deserialize(body)
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 550128a..11fdebf 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -20,6 +20,24 @@
     version = '1'
     uri_prefix = 'v1'
 
+    @staticmethod
+    def _get_headers(api_version):
+        """Return headers for a request.
+
+        Currently supports a header specifying the API version to use.
+
+        :param api_version: Ironic API version to use.
+        :return: a 2-tuple of (extra_headers, headers), where 'extra_headers'
+            is whether to use headers, and 'headers' is a list of headers to
+            use in the request.
+        """
+        extra_headers = False
+        headers = None
+        if api_version is not None:
+            extra_headers = True
+            headers = {'x-openstack-ironic-api-version': api_version}
+        return extra_headers, headers
+
     @base.handle_errors
     def list_nodes(self, **kwargs):
         """List all existing nodes."""
@@ -81,28 +99,33 @@
         return self._list_request('drivers')
 
     @base.handle_errors
-    def show_node(self, uuid):
+    def show_node(self, uuid, api_version=None):
         """Gets a specific node.
 
         :param uuid: Unique identifier of the node in UUID format.
+        :param api_version: Ironic API version to use.
         :return: Serialized node as a dictionary.
 
         """
-        return self._show_request('nodes', uuid)
+        extra_headers, headers = self._get_headers(api_version)
+        return self._show_request('nodes', uuid, headers=headers,
+                                  extra_headers=extra_headers)
 
     @base.handle_errors
-    def show_node_by_instance_uuid(self, instance_uuid):
+    def show_node_by_instance_uuid(self, instance_uuid, api_version=None):
         """Gets a node associated with given instance uuid.
 
         :param instance_uuid: Unique identifier of the instance in UUID format.
+        :param api_version: Ironic API version to use.
         :return: Serialized node as a dictionary.
 
         """
         uri = '/nodes/detail?instance_uuid=%s' % instance_uuid
-
+        extra_headers, headers = self._get_headers(api_version)
         return self._show_request('nodes',
                                   uuid=None,
-                                  uri=uri)
+                                  uri=uri, headers=headers,
+                                  extra_headers=extra_headers)
 
     @base.handle_errors
     def show_chassis(self, uuid):
@@ -388,6 +411,7 @@
                            'properties/memory_mb',
                            'driver',
                            'deploy_interface',
+                           'rescue_interface',
                            'instance_uuid',
                            'resource_class')
         if not patch:
@@ -460,7 +484,7 @@
 
     @base.handle_errors
     def set_node_provision_state(self, node_uuid, state, configdrive=None,
-                                 clean_steps=None):
+                                 clean_steps=None, rescue_password=None):
         """Set provision state of the specified node.
 
         :param node_uuid: The unique identifier of the node.
@@ -469,6 +493,7 @@
         :param configdrive: A gzipped, base64-encoded
             configuration drive string.
         :param clean_steps: A list with clean steps to execute.
+        :param rescue_password: user password used to rescue.
         """
         data = {'target': state}
         # NOTE (vsaienk0): Add both here if specified, do not check anything.
@@ -477,6 +502,8 @@
             data['configdrive'] = configdrive
         if clean_steps is not None:
             data['clean_steps'] = clean_steps
+        if rescue_password is not None:
+            data['rescue_password'] = rescue_password
         return self._put_request('nodes/%s/states/provision' % node_uuid,
                                  data)
 
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
index b4a8169..c220619 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
@@ -69,6 +69,7 @@
     DELETED = 'deleted'
     ERROR = 'error'
     MANAGEABLE = 'manageable'
+    RESCUE = 'rescue'
 
 
 class BaremetalScenarioTest(manager.ScenarioTest):
@@ -119,8 +120,9 @@
                                                       instance_id)
 
     @classmethod
-    def get_node(cls, node_id=None, instance_id=None):
-        return utils.get_node(cls.baremetal_client, node_id, instance_id)
+    def get_node(cls, node_id=None, instance_id=None, api_version=None):
+        return utils.get_node(cls.baremetal_client, node_id, instance_id,
+                              api_version)
 
     def get_ports(self, node_uuid):
         ports = []
@@ -153,9 +155,10 @@
     @classmethod
     @retry_on_conflict
     def set_node_provision_state(cls, node_id, state, configdrive=None,
-                                 clean_steps=None):
+                                 clean_steps=None, rescue_password=None):
         cls.baremetal_client.set_node_provision_state(
-            node_id, state, configdrive=configdrive, clean_steps=clean_steps)
+            node_id, state, configdrive=configdrive,
+            clean_steps=clean_steps, rescue_password=rescue_password)
 
     def verify_connectivity(self, ip=None):
         if ip:
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
index 0137db8..9eb5d7f 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
@@ -146,6 +146,18 @@
         return floating_ip['floating_ip_address']
 
     @classmethod
+    def get_server_ip(cls, node_id):
+        """Get the server fixed IP.
+
+        :param node_id: Name or UUID of the node.
+        :returns: IP address of associated fixed IP.
+        """
+        vif = cls.get_node_vifs(node_id)[0]
+        body = cls.ports_client.show_port(vif)['port']
+        fixed_ip = body['fixed_ips'][0]
+        return fixed_ip['ip_address']
+
+    @classmethod
     def cleanup_floating_ip(cls, ip_address):
         """Removes floating IP."""
         body = cls.os_admin.floating_ips_client.list_floatingips()
@@ -272,6 +284,27 @@
             timeout=CONF.baremetal.unprovision_timeout,
             interval=1)
 
+    @classmethod
+    def rescue_node(cls, node_id, rescue_password):
+        """Rescue the node."""
+        cls.set_node_provision_state(node_id, 'rescue',
+                                     rescue_password=rescue_password)
+        cls.wait_provisioning_state(
+            node_id,
+            bm.BaremetalProvisionStates.RESCUE,
+            timeout=CONF.baremetal.rescue_timeout,
+            interval=1)
+
+    @classmethod
+    def unrescue_node(cls, node_id):
+        """Unrescue the node."""
+        cls.set_node_provision_state(node_id, 'unrescue')
+        cls.wait_provisioning_state(
+            node_id,
+            bm.BaremetalProvisionStates.ACTIVE,
+            timeout=CONF.baremetal.unrescue_timeout,
+            interval=1)
+
 
 class BaremetalStandaloneScenarioTest(BaremetalStandaloneManager):
 
@@ -281,9 +314,18 @@
     # The node driver to use in the test
     driver = None
 
-    # The deploy interface to use by the HW type
+    # The deploy interface to use by the HW type. The deploy interface of
+    # the node used in the test will be set to this value. If set to None,
+    # the node will retain its existing deploy_interface value (which may have
+    # been set via a different test).
     deploy_interface = None
 
+    # The rescue interface to use by the HW type. The rescue interface of
+    # the node used in the test will be set to this value. If set to None,
+    # the node will retain its existing rescue_interface value (which may have
+    # been set via a different test).
+    rescue_interface = None
+
     # User image ref to boot node with.
     image_ref = None
 
@@ -318,6 +360,13 @@
                 "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 not cls.wholedisk_image and CONF.baremetal.use_provision_network:
             raise cls.skipException(
                 'Partitioned images are not supported with multitenancy.')
@@ -336,12 +385,22 @@
         boot_kwargs = {'image_checksum': image_checksum}
         if cls.deploy_interface:
             boot_kwargs['deploy_interface'] = cls.deploy_interface
+        if cls.rescue_interface:
+            boot_kwargs['rescue_interface'] = cls.rescue_interface
         cls.node = cls.boot_node(cls.driver, cls.image_ref, **boot_kwargs)
-        cls.node_ip = cls.add_floatingip_to_node(cls.node['uuid'])
+        if CONF.validation.connect_method == 'floating':
+            cls.node_ip = cls.add_floatingip_to_node(cls.node['uuid'])
+        elif CONF.validation.connect_method == 'fixed':
+            cls.node_ip = cls.get_server_ip(cls.node['uuid'])
+        else:
+            m = ('Configuration option "[validation]/connect_method" '
+                 'must be set.')
+            raise lib_exc.InvalidConfiguration(m)
 
     @classmethod
     def resource_cleanup(cls):
-        cls.cleanup_floating_ip(cls.node_ip)
+        if CONF.validation.connect_method == 'floating':
+            cls.cleanup_floating_ip(cls.node_ip)
         vifs = cls.get_node_vifs(cls.node['uuid'])
         # Remove ports before deleting node, to catch regression for cases
         # when user did this prior unprovision node.
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 946a0e6..f90f8ca 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
@@ -192,3 +192,57 @@
     def test_ip_access_to_server(self):
         self.assertTrue(self.ping_ip_address(self.node_ip,
                                              should_succeed=True))
+
+
+class BaremetalIpmiRescueWholedisk(bsm.BaremetalStandaloneScenarioTest):
+
+    api_microversion = '1.38'
+    min_microversion = '1.38'
+    driver = 'ipmi'
+    rescue_interface = 'agent'
+    image_ref = CONF.baremetal.whole_disk_image_ref
+    wholedisk_image = True
+
+    # NOTE(tiendc) Using direct deploy interface and a whole disk
+    # image may lead to the bug:
+    # https://bugs.launchpad.net/ironic/+bug/1750958
+    # This is a workaround by using iscsi deploy interface.
+    deploy_interface = 'iscsi'
+
+    @decorators.idempotent_id('d6a1780f-c4bb-4136-8144-29e822e14d66')
+    @utils.services('image', 'network')
+    def test_rescue_mode(self):
+        self.rescue_node(self.node['uuid'], 'abc123')
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+        self.unrescue_node(self.node['uuid'])
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalIpmiRescuePartitioned(bsm.BaremetalStandaloneScenarioTest):
+
+    api_microversion = '1.38'
+    min_microversion = '1.38'
+    driver = 'ipmi'
+    rescue_interface = 'agent'
+    image_ref = CONF.baremetal.partition_image_ref
+    wholedisk_image = False
+
+    # NOTE(jroll) the ansible deploy interface doesn't support partition images
+    # with netboot mode. Since that's what is happening here, explicitly choose
+    # a deploy interface to be sure we don't end up with a node using the
+    # ansible interface here.
+    deploy_interface = 'iscsi'
+
+    @decorators.idempotent_id('113acd0a-9872-4631-b3ee-54da7e3bb262')
+    @utils.services('image', 'network')
+    def test_rescue_mode(self):
+        self.rescue_node(self.node['uuid'], 'abc123')
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+        self.unrescue_node(self.node['uuid'])
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
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 47fc07e..f78155d 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
@@ -35,6 +35,7 @@
         * Monitors the associated Ironic node for power and
           expected state transitions
         * Validates Ironic node's port data has been properly updated
+        * Validates Ironic node's resource class and traits have been honoured
         * Verifies SSH connectivity using created keypair via fixed IP
         * Associates a floating ip
         * Verifies SSH connectivity using created keypair via floating IP
@@ -44,6 +45,16 @@
           expected state transitions
     """
 
+    @staticmethod
+    def _is_version_supported(version):
+        """Return whether an API microversion is supported."""
+        min_version = api_version_request.APIVersionRequest(
+            CONF.baremetal.min_microversion)
+        max_version = api_version_request.APIVersionRequest(
+            CONF.baremetal.max_microversion)
+        version = api_version_request.APIVersionRequest(version)
+        return min_version <= version <= max_version
+
     def rebuild_instance(self, preserve_ephemeral=False):
         self.rebuild_server(server_id=self.instance['id'],
                             preserve_ephemeral=preserve_ephemeral,
@@ -105,9 +116,7 @@
         vifs = []
         # TODO(vsaienko) switch to get_node_vifs() when all stable releases
         # supports Ironic API 1.28
-        if (api_version_request.APIVersionRequest(
-            CONF.baremetal.max_microversion) >=
-                api_version_request.APIVersionRequest('1.28')):
+        if self._is_version_supported('1.28'):
             vifs = self.get_node_vifs(node_uuid)
         else:
             for port in self.get_ports(self.node['uuid']):
@@ -124,12 +133,65 @@
             self.assertEqual(n_port['device_id'], self.instance['id'])
             self.assertIn(n_port['mac_address'], ir_ports_addresses)
 
+    def validate_scheduling(self):
+        """Validate scheduling attributes of the node against the flavor.
+
+        Validates the resource class and traits requested by the flavor against
+        those set on the node. Does not assume that resource classes and traits
+        are in use.
+        """
+        # Try to get a node with resource class (1.21) and traits (1.37).
+        # TODO(mgoddard): Remove this when all stable releases support these
+        # API versions.
+        for version in ('1.37', '1.21'):
+            if self._is_version_supported(version):
+                node = self.get_node(instance_id=self.instance['id'],
+                                     api_version=version)
+                break
+        else:
+            # Neither API is supported - cannot test.
+            LOG.warning("Cannot validate resource class and trait based "
+                        "scheduling as these require API version 1.21 and "
+                        "1.37 respectively")
+            return
+
+        f_id = self.instance['flavor']['id']
+        extra_specs = self.flavors_client.list_flavor_extra_specs(f_id)
+        extra_specs = extra_specs['extra_specs']
+
+        # Pull the requested resource class and traits from the flavor.
+        resource_class = None
+        traits = set()
+        for key, value in extra_specs.items():
+            if key.startswith('resources:CUSTOM_') and value == '1':
+                resource_class = key.partition(':')[2]
+            if key.startswith('trait:') and value == 'required':
+                trait = key.partition(':')[2]
+                traits.add(trait)
+
+        # Validate requested resource class and traits against the node.
+        if resource_class is not None:
+            # The resource class in ironic may be lower case, and must omit the
+            # CUSTOM_ prefix. Normalise it.
+            node_resource_class = node['resource_class']
+            node_resource_class = node_resource_class.upper()
+            node_resource_class = 'CUSTOM_' + node_resource_class
+            self.assertEqual(resource_class, node_resource_class)
+
+        if 'traits' in node and traits:
+            self.assertIn('traits', node['instance_info'])
+            # All flavor traits should be added as instance traits.
+            self.assertEqual(traits, set(node['instance_info']['traits']))
+            # Flavor traits should be a subset of node traits.
+            self.assertTrue(traits.issubset(set(node['traits'])))
+
     @decorators.idempotent_id('549173a5-38ec-42bb-b0e2-c8b9f4a08943')
     @utils.services('compute', 'image', 'network')
     def test_baremetal_server_ops(self):
         self.add_keypair()
         self.instance, self.node = self.boot_instance()
         self.validate_ports()
+        self.validate_scheduling()
         ip_address = self.get_server_ip(self.instance)
         self.get_remote_client(ip_address).validate_authentication()
         vm_client = self.get_remote_client(ip_address)
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
index 013eb2e..69ee6c8 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
@@ -44,6 +44,11 @@
         if not CONF.baremetal.use_provision_network:
             msg = 'Ironic/Neutron tenant isolation is not configured.'
             raise cls.skipException(msg)
+        if (CONF.baremetal.available_nodes is not None and
+                CONF.baremetal.available_nodes < 2):
+            msg = ('Not enough baremetal nodes, %d configured, test requires '
+                   'a minimum of 2') % CONF.baremetal.available_nodes
+            raise cls.skipException(msg)
 
     def create_tenant_network(self, clients, tenant_cidr):
         network = self._create_network(
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index e6646a2..a38e8ec 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -4,10 +4,10 @@
         - ironic-dsvm-standalone
         - ironic-dsvm-standalone-queens
         - ironic-dsvm-standalone-pike
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-queens
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-pike
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-ocata
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-queens
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-pike
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-ocata
         - ironic-tempest-dsvm-ironic-inspector
         - ironic-tempest-dsvm-ironic-inspector-queens
         - ironic-tempest-dsvm-ironic-inspector-pike
@@ -21,10 +21,10 @@
         - ironic-dsvm-standalone
         - ironic-dsvm-standalone-queens
         - ironic-dsvm-standalone-pike
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-queens
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-pike
-        - ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-ocata
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-queens
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-pike
+        - ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-ocata
         - ironic-tempest-dsvm-ironic-inspector
         - ironic-tempest-dsvm-ironic-inspector-queens
         - ironic-tempest-dsvm-ironic-inspector-pike
diff --git a/zuul.d/stable-jobs.yaml b/zuul.d/stable-jobs.yaml
index 5b51164..32c5080 100644
--- a/zuul.d/stable-jobs.yaml
+++ b/zuul.d/stable-jobs.yaml
@@ -10,18 +10,18 @@
     override-checkout: stable/pike
 
 - job:
-    name: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-queens
-    parent: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
+    name: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-queens
+    parent: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode
     override-checkout: stable/queens
 
 - job:
-    name: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-pike
-    parent: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
+    name: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-pike
+    parent: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode
     override-checkout: stable/pike
 
 - job:
-    name: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa-ocata
-    parent: ironic-tempest-dsvm-ipa-wholedisk-bios-agent_ipmitool-tinyipa
+    name: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode-ocata
+    parent: ironic-tempest-dsvm-ipa-wholedisk-agent_ipmitool-tinyipa-multinode
     override-checkout: stable/ocata
 
 - job: