Merge remote-tracking branch 'inspector/master'
diff --git a/ironic_tempest_plugin/README.rst b/ironic_tempest_plugin/README.rst
index 0ea008c..6de7741 100644
--- a/ironic_tempest_plugin/README.rst
+++ b/ironic_tempest_plugin/README.rst
@@ -1,18 +1,15 @@
-=======================================
-Tempest Integration of ironic-inspector
-=======================================
+=====================
+Ironic tempest plugin
+=====================
 
-This directory contains Tempest tests to cover the ironic-inspector project.
+This directory contains Tempest tests to cover the ironic and ironic-inspector
+projects, as well as a plugin to automatically load these tests into tempest.
 
-It uses tempest plugin to automatically load these tests into tempest. More
-information about tempest plugin could be found here:
-`Plugin <https://docs.openstack.org/tempest/latest/plugin.html>`_
+See the tempest plugin documentation for information about creating
+a plugin, stable API interface, TempestPlugin class interface, plugin
+structure, and how to use plugins:
+https://docs.openstack.org/tempest/latest/plugin.html
 
-The legacy method of running Tempest is to just treat the Tempest source code
-as a python unittest:
-`Run tests <https://docs.openstack.org/tempest/latest/overview.html#legacy-run-method>`_
-
-There is also tox configuration for tempest, use following regex for running
-introspection tests::
-
-    $ tox -e all-plugin -- inspector_tempest_plugin
+See the Ironic documentation for information about how to run the
+tempest tests:
+https://docs.openstack.org/ironic/latest/contributor/dev-quickstart.html#running-tempest-tests
diff --git a/ironic_tempest_plugin/clients.py b/ironic_tempest_plugin/clients.py
new file mode 100644
index 0000000..2b81878
--- /dev/null
+++ b/ironic_tempest_plugin/clients.py
@@ -0,0 +1,53 @@
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from tempest import clients
+from tempest.common import credentials_factory as common_creds
+from tempest import config
+
+from ironic_tempest_plugin.services.baremetal.v1.json.baremetal_client import \
+    BaremetalClient
+
+
+CONF = config.CONF
+
+ADMIN_CREDS = None
+
+
+class Manager(clients.Manager):
+    def __init__(self,
+                 credentials=None):
+        """Initialization of Manager class.
+
+        Setup service client and make it available for test cases.
+        :param credentials: type Credentials or TestResources
+        """
+        if credentials is None:
+            global ADMIN_CREDS
+            if ADMIN_CREDS is None:
+                ADMIN_CREDS = common_creds.get_configured_admin_credentials()
+            credentials = ADMIN_CREDS
+        super(Manager, self).__init__(credentials)
+        default_params_with_timeout_values = {
+            'build_interval': CONF.compute.build_interval,
+            'build_timeout': CONF.compute.build_timeout
+        }
+        default_params_with_timeout_values.update(self.default_params)
+
+        self.baremetal_client = BaremetalClient(
+            self.auth_provider,
+            CONF.baremetal.catalog_type,
+            CONF.identity.region,
+            endpoint_type=CONF.baremetal.endpoint_type,
+            **default_params_with_timeout_values)
diff --git a/ironic_tempest_plugin/common/__init__.py b/ironic_tempest_plugin/common/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/common/__init__.py
diff --git a/ironic_tempest_plugin/common/utils.py b/ironic_tempest_plugin/common/utils.py
new file mode 100644
index 0000000..67c4922
--- /dev/null
+++ b/ironic_tempest_plugin/common/utils.py
@@ -0,0 +1,33 @@
+#    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.
+
+
+def get_node(client, node_id=None, instance_uuid=None):
+    """Get a node by its identifier or instance UUID.
+
+    If both node_id and instance_uuid specified, node_id will be used.
+
+    :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.
+    :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)
+        return body
+    elif instance_uuid:
+        _, body = client.show_node_by_instance_uuid(instance_uuid)
+        if body['nodes']:
+            return body['nodes'][0]
diff --git a/ironic_tempest_plugin/common/waiters.py b/ironic_tempest_plugin/common/waiters.py
new file mode 100644
index 0000000..b706ac0
--- /dev/null
+++ b/ironic_tempest_plugin/common/waiters.py
@@ -0,0 +1,112 @@
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import six
+from tempest import config
+from tempest.lib.common.utils import test_utils
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin.common import utils
+
+CONF = config.CONF
+
+
+def _determine_and_check_timeout_interval(timeout, default_timeout,
+                                          interval, default_interval):
+    if timeout is None:
+        timeout = default_timeout
+    if interval is None:
+        interval = default_interval
+    if (not isinstance(timeout, six.integer_types) or
+            not isinstance(interval, six.integer_types) or
+            timeout < 0 or interval < 0):
+        raise AssertionError(
+            'timeout and interval should be >= 0 or None, current values are: '
+            '%(timeout)s, %(interval)s respectively. If timeout and/or '
+            'interval are None, the default_timeout and default_interval are '
+            'used, and they should be integers >= 0, current values are: '
+            '%(default_timeout)s, %(default_interval)s respectively.' % dict(
+                timeout=timeout, interval=interval,
+                default_timeout=default_timeout,
+                default_interval=default_interval)
+        )
+    return timeout, interval
+
+
+def wait_for_bm_node_status(client, node_id, attr, status, timeout=None,
+                            interval=None):
+    """Waits for a baremetal node attribute to reach given status.
+
+    :param client: an instance of tempest plugin BaremetalClient.
+    :param node_id: identifier of the node.
+    :param attr: node's API-visible attribute to check status of.
+    :param status: desired status. Can be a list of statuses.
+    :param timeout: the timeout after which the check is considered as failed.
+        Defaults to client.build_timeout.
+    :param interval: an interval between show_node calls for status check.
+        Defaults to client.build_interval.
+
+    The client should have a show_node(node_id) method to get the node.
+    """
+    timeout, interval = _determine_and_check_timeout_interval(
+        timeout, client.build_timeout, interval, client.build_interval)
+
+    if not isinstance(status, list):
+        status = [status]
+
+    def is_attr_in_status():
+        node = utils.get_node(client, node_id=node_id)
+        if node[attr] in status:
+            return True
+        return False
+
+    if not test_utils.call_until_true(is_attr_in_status, timeout,
+                                      interval):
+        message = ('Node %(node_id)s failed to reach %(attr)s=%(status)s '
+                   'within the required time (%(timeout)s s).' %
+                   {'node_id': node_id,
+                    'attr': attr,
+                    'status': status,
+                    'timeout': timeout})
+        caller = test_utils.find_test_caller()
+        if caller:
+            message = '(%s) %s' % (caller, message)
+        raise lib_exc.TimeoutException(message)
+
+
+def wait_node_instance_association(client, instance_uuid, timeout=None,
+                                   interval=None):
+    """Waits for a node to be associated with instance_id.
+
+    :param client: an instance of tempest plugin BaremetalClient.
+    :param instance_uuid: UUID of the instance.
+    :param timeout: the timeout after which the check is considered as failed.
+        Defaults to CONF.baremetal.association_timeout.
+    :param interval: an interval between show_node calls for status check.
+        Defaults to client.build_interval.
+    """
+    timeout, interval = _determine_and_check_timeout_interval(
+        timeout, CONF.baremetal.association_timeout,
+        interval, client.build_interval)
+
+    def is_some_node_associated():
+        node = utils.get_node(client, instance_uuid=instance_uuid)
+        return node is not None
+
+    if not test_utils.call_until_true(is_some_node_associated, timeout,
+                                      interval):
+        msg = ('Timed out waiting to get Ironic node by instance UUID '
+               '%(instance_uuid)s within the required time (%(timeout)s s).'
+               % {'instance_uuid': instance_uuid, 'timeout': timeout})
+        raise lib_exc.TimeoutException(msg)
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index e586900..aa416b8 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -1,3 +1,6 @@
+# Copyright 2015 NEC Corporation
+# All Rights Reserved.
+#
 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
 #    not use this file except in compliance with the License. You may obtain
 #    a copy of the License at
@@ -12,10 +15,28 @@
 
 from oslo_config import cfg
 
-service_option = cfg.BoolOpt("ironic-inspector",
-                             default=True,
-                             help="Whether or not ironic-inspector is expected"
-                                  " to be available")
+from tempest import config  # noqa
+
+
+ironic_service_option = cfg.BoolOpt('ironic',
+                                    default=False,
+                                    help='Whether or not ironic is expected '
+                                    'to be available')
+
+inspector_service_option = cfg.BoolOpt("ironic-inspector",
+                                       default=True,
+                                       help="Whether or not ironic-inspector "
+                                       "is expected to be available")
+
+baremetal_group = cfg.OptGroup(name='baremetal',
+                               title='Baremetal provisioning service options',
+                               help='When enabling baremetal tests, Nova '
+                                    'must be configured to use the Ironic '
+                                    'driver. The following parameters for the '
+                                    '[compute] section must be disabled: '
+                                    'console_output, interface_attach, '
+                                    'live_migration, pause, rescue, resize, '
+                                    'shelve, snapshot, and suspend')
 
 baremetal_introspection_group = cfg.OptGroup(
     name="baremetal_introspection",
@@ -23,6 +44,87 @@
     help="When enabling baremetal introspection tests,"
          "Ironic must be configured.")
 
+baremetal_features_group = cfg.OptGroup(
+    name='baremetal_feature_enabled',
+    title="Enabled Baremetal Service Features")
+
+BaremetalGroup = [
+    cfg.StrOpt('catalog_type',
+               default='baremetal',
+               help="Catalog type of the baremetal provisioning service"),
+    cfg.StrOpt('driver',
+               default='fake',
+               help="Driver name which Ironic uses"),
+    cfg.StrOpt('endpoint_type',
+               default='publicURL',
+               choices=['public', 'admin', 'internal',
+                        'publicURL', 'adminURL', 'internalURL'],
+               help="The endpoint type to use for the baremetal provisioning"
+                    " service"),
+    cfg.IntOpt('deploywait_timeout',
+               default=15,
+               help="Timeout for Ironic node to reach the "
+                    "wait-callback state after powering on."),
+    cfg.IntOpt('active_timeout',
+               default=300,
+               help="Timeout for Ironic node to completely provision"),
+    cfg.IntOpt('association_timeout',
+               default=30,
+               help="Timeout for association of Nova instance and Ironic "
+                    "node"),
+    cfg.IntOpt('power_timeout',
+               default=60,
+               help="Timeout for Ironic power transitions."),
+    cfg.IntOpt('unprovision_timeout',
+               default=300,
+               help="Timeout for unprovisioning an Ironic node. "
+                    "Takes longer since Kilo as Ironic performs an extra "
+                    "step in Node cleaning."),
+    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. "
+                    "Tempest selects tests based on the range between "
+                    "min_microversion and max_microversion. "
+                    "If both values are None, Tempest avoids tests which "
+                    "require a microversion."),
+    cfg.StrOpt('max_microversion',
+               default='latest',
+               help="Upper version of the test target microversion range. "
+                    "The format is 'X.Y', where 'X' and 'Y' are int values. "
+                    "Tempest selects tests based on the range between "
+                    "min_microversion and max_microversion. "
+                    "If both values are None, Tempest avoids tests which "
+                    "require a microversion."),
+    cfg.BoolOpt('use_provision_network',
+                default=False,
+                help="Whether the Ironic/Neutron tenant isolation is enabled"),
+    cfg.StrOpt('whole_disk_image_ref',
+               help="UUID of the wholedisk image to use in the tests."),
+    cfg.StrOpt('whole_disk_image_url',
+               help="An http link to the wholedisk image to use in the "
+                    "tests."),
+    cfg.StrOpt('whole_disk_image_checksum',
+               help="An MD5 checksum of the image."),
+    cfg.StrOpt('partition_image_ref',
+               help="UUID of the partitioned image to use in the tests."),
+    cfg.ListOpt('enabled_drivers',
+                default=['fake', 'pxe_ipmitool', 'agent_ipmitool'],
+                help="List of Ironic enabled drivers."),
+    cfg.ListOpt('enabled_hardware_types',
+                default=['ipmi'],
+                help="List of Ironic enabled hardware types."),
+    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."),
+]
+
+BaremetalFeaturesGroup = [
+    cfg.BoolOpt('ipxe_enabled',
+                default=True,
+                help="Defines if IPXE is enabled"),
+]
+
 BaremetalIntrospectionGroup = [
     cfg.StrOpt('catalog_type',
                default='baremetal-introspection',
diff --git a/ironic_tempest_plugin/manager.py b/ironic_tempest_plugin/manager.py
new file mode 100644
index 0000000..9967a5d
--- /dev/null
+++ b/ironic_tempest_plugin/manager.py
@@ -0,0 +1,559 @@
+# Copyright 2012 OpenStack Foundation
+# Copyright 2013 IBM Corp.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+# NOTE(soliosg) Do not edit this file. It will only stay temporarily
+# in ironic, while QA refactors the tempest.scenario interface. This
+# file was copied from openstack/tempest/tempest/scenario/manager.py,
+# openstack/tempest commit: 82a278e88c9e9f9ba49f81c1f8dba0bca7943daf
+
+import subprocess
+
+from oslo_log import log
+from oslo_utils import netutils
+from tempest.common import compute
+from tempest.common.utils.linux import remote_client
+from tempest.common.utils import net_utils
+from tempest.common import waiters
+from tempest import config
+from tempest import exceptions
+from tempest.lib.common.utils import data_utils
+from tempest.lib.common.utils import test_utils
+from tempest.lib import exceptions as lib_exc
+import tempest.test
+
+CONF = config.CONF
+
+LOG = log.getLogger(__name__)
+
+
+class ScenarioTest(tempest.test.BaseTestCase):
+    """Base class for scenario tests. Uses tempest own clients. """
+
+    credentials = ['primary']
+
+    @classmethod
+    def setup_clients(cls):
+        super(ScenarioTest, cls).setup_clients()
+        # Clients (in alphabetical order)
+        cls.flavors_client = cls.os_primary.flavors_client
+        cls.compute_floating_ips_client = (
+            cls.os_primary.compute_floating_ips_client)
+        if CONF.service_available.glance:
+            # Check if glance v1 is available to determine which client to use.
+            if CONF.image_feature_enabled.api_v1:
+                cls.image_client = cls.os_primary.image_client
+            elif CONF.image_feature_enabled.api_v2:
+                cls.image_client = cls.os_primary.image_client_v2
+            else:
+                raise lib_exc.InvalidConfiguration(
+                    'Either api_v1 or api_v2 must be True in '
+                    '[image-feature-enabled].')
+        # Compute image client
+        cls.compute_images_client = cls.os_primary.compute_images_client
+        cls.keypairs_client = cls.os_primary.keypairs_client
+        # Nova security groups client
+        cls.compute_security_groups_client = (
+            cls.os_primary.compute_security_groups_client)
+        cls.compute_security_group_rules_client = (
+            cls.os_primary.compute_security_group_rules_client)
+        cls.servers_client = cls.os_primary.servers_client
+        cls.interface_client = cls.os_primary.interfaces_client
+        # Neutron network client
+        cls.networks_client = cls.os_primary.networks_client
+        cls.ports_client = cls.os_primary.ports_client
+        cls.routers_client = cls.os_primary.routers_client
+        cls.subnets_client = cls.os_primary.subnets_client
+        cls.floating_ips_client = cls.os_primary.floating_ips_client
+        cls.security_groups_client = cls.os_primary.security_groups_client
+        cls.security_group_rules_client = (
+            cls.os_primary.security_group_rules_client)
+
+        if CONF.volume_feature_enabled.api_v2:
+            cls.volumes_client = cls.os_primary.volumes_v2_client
+            cls.snapshots_client = cls.os_primary.snapshots_v2_client
+        else:
+            cls.volumes_client = cls.os_primary.volumes_client
+            cls.snapshots_client = cls.os_primary.snapshots_client
+
+    # ## Test functions library
+    #
+    # The create_[resource] functions only return body and discard the
+    # resp part which is not used in scenario tests
+
+    def _create_port(self, network_id, client=None, namestart='port-quotatest',
+                     **kwargs):
+        if not client:
+            client = self.ports_client
+        name = data_utils.rand_name(namestart)
+        result = client.create_port(
+            name=name,
+            network_id=network_id,
+            **kwargs)
+        self.assertIsNotNone(result, 'Unable to allocate port')
+        port = result['port']
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        client.delete_port, port['id'])
+        return port
+
+    def create_keypair(self, client=None):
+        if not client:
+            client = self.keypairs_client
+        name = data_utils.rand_name(self.__class__.__name__)
+        # We don't need to create a keypair by pubkey in scenario
+        body = client.create_keypair(name=name)
+        self.addCleanup(client.delete_keypair, name)
+        return body['keypair']
+
+    def create_server(self, name=None, image_id=None, flavor=None,
+                      validatable=False, wait_until='ACTIVE',
+                      clients=None, **kwargs):
+        """Wrapper utility that returns a test server.
+
+        This wrapper utility calls the common create test server and
+        returns a test server. The purpose of this wrapper is to minimize
+        the impact on the code of the tests already using this
+        function.
+        """
+
+        # NOTE(jlanoux): As a first step, ssh checks in the scenario
+        # tests need to be run regardless of the run_validation and
+        # validatable parameters and thus until the ssh validation job
+        # becomes voting in CI. The test resources management and IP
+        # association are taken care of in the scenario tests.
+        # Therefore, the validatable parameter is set to false in all
+        # those tests. In this way create_server just return a standard
+        # server and the scenario tests always perform ssh checks.
+
+        # Needed for the cross_tenant_traffic test:
+        if clients is None:
+            clients = self.os_primary
+
+        if name is None:
+            name = data_utils.rand_name(self.__class__.__name__ + "-server")
+
+        vnic_type = CONF.network.port_vnic_type
+
+        # If vnic_type is configured create port for
+        # every network
+        if vnic_type:
+            ports = []
+
+            create_port_body = {'binding:vnic_type': vnic_type,
+                                'namestart': 'port-smoke'}
+            if kwargs:
+                # Convert security group names to security group ids
+                # to pass to create_port
+                if 'security_groups' in kwargs:
+                    security_groups = \
+                        clients.security_groups_client.list_security_groups(
+                        ).get('security_groups')
+                    sec_dict = dict([(s['name'], s['id'])
+                                    for s in security_groups])
+
+                    sec_groups_names = [s['name'] for s in kwargs.pop(
+                        'security_groups')]
+                    security_groups_ids = [sec_dict[s]
+                                           for s in sec_groups_names]
+
+                    if security_groups_ids:
+                        create_port_body[
+                            'security_groups'] = security_groups_ids
+                networks = kwargs.pop('networks', [])
+            else:
+                networks = []
+
+            # If there are no networks passed to us we look up
+            # for the project's private networks and create a port.
+            # The same behaviour as we would expect when passing
+            # the call to the clients with no networks
+            if not networks:
+                networks = clients.networks_client.list_networks(
+                    **{'router:external': False, 'fields': 'id'})['networks']
+
+            # It's net['uuid'] if networks come from kwargs
+            # and net['id'] if they come from
+            # clients.networks_client.list_networks
+            for net in networks:
+                net_id = net.get('uuid', net.get('id'))
+                if 'port' not in net:
+                    port = self._create_port(network_id=net_id,
+                                             client=clients.ports_client,
+                                             **create_port_body)
+                    ports.append({'port': port['id']})
+                else:
+                    ports.append({'port': net['port']})
+            if ports:
+                kwargs['networks'] = ports
+            self.ports = ports
+
+        tenant_network = self.get_tenant_network()
+
+        body, servers = compute.create_test_server(
+            clients,
+            tenant_network=tenant_network,
+            wait_until=wait_until,
+            name=name, flavor=flavor,
+            image_id=image_id, **kwargs)
+
+        self.addCleanup(waiters.wait_for_server_termination,
+                        clients.servers_client, body['id'])
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        clients.servers_client.delete_server, body['id'])
+        server = clients.servers_client.show_server(body['id'])['server']
+        return server
+
+    def get_remote_client(self, ip_address, username=None, private_key=None):
+        """Get a SSH client to a remote server
+
+        @param ip_address the server floating or fixed IP address to use
+                          for ssh validation
+        @param username name of the Linux account on the remote server
+        @param private_key the SSH private key to use
+        @return a RemoteClient object
+        """
+
+        if username is None:
+            username = CONF.validation.image_ssh_user
+        # Set this with 'keypair' or others to log in with keypair or
+        # username/password.
+        if CONF.validation.auth_method == 'keypair':
+            password = None
+            if private_key is None:
+                private_key = self.keypair['private_key']
+        else:
+            password = CONF.validation.image_ssh_password
+            private_key = None
+        linux_client = remote_client.RemoteClient(ip_address, username,
+                                                  pkey=private_key,
+                                                  password=password)
+        try:
+            linux_client.validate_authentication()
+        except Exception as e:
+            message = ('Initializing SSH connection to %(ip)s failed. '
+                       'Error: %(error)s' % {'ip': ip_address,
+                                             'error': e})
+            caller = test_utils.find_test_caller()
+            if caller:
+                message = '(%s) %s' % (caller, message)
+            LOG.exception(message)
+            self._log_console_output()
+            raise
+
+        return linux_client
+
+    def _log_console_output(self, servers=None):
+        if not CONF.compute_feature_enabled.console_output:
+            LOG.debug('Console output not supported, cannot log')
+            return
+        if not servers:
+            servers = self.servers_client.list_servers()
+            servers = servers['servers']
+        for server in servers:
+            try:
+                console_output = self.servers_client.get_console_output(
+                    server['id'])['output']
+                LOG.debug('Console output for %s\nbody=\n%s',
+                          server['id'], console_output)
+            except lib_exc.NotFound:
+                LOG.debug("Server %s disappeared(deleted) while looking "
+                          "for the console log", server['id'])
+
+    def rebuild_server(self, server_id, image=None,
+                       preserve_ephemeral=False, wait=True,
+                       rebuild_kwargs=None):
+        if image is None:
+            image = CONF.compute.image_ref
+
+        rebuild_kwargs = rebuild_kwargs or {}
+
+        LOG.debug("Rebuilding server (id: %s, image: %s, preserve eph: %s)",
+                  server_id, image, preserve_ephemeral)
+        self.servers_client.rebuild_server(
+            server_id=server_id, image_ref=image,
+            preserve_ephemeral=preserve_ephemeral,
+            **rebuild_kwargs)
+        if wait:
+            waiters.wait_for_server_status(self.servers_client,
+                                           server_id, 'ACTIVE')
+
+    def ping_ip_address(self, ip_address, should_succeed=True,
+                        ping_timeout=None, mtu=None):
+        timeout = ping_timeout or CONF.validation.ping_timeout
+        cmd = ['ping', '-c1', '-w1']
+
+        if mtu:
+            cmd += [
+                # don't fragment
+                '-M', 'do',
+                # ping receives just the size of ICMP payload
+                '-s', str(net_utils.get_ping_payload_size(mtu, 4))
+            ]
+        cmd.append(ip_address)
+
+        def ping():
+            proc = subprocess.Popen(cmd,
+                                    stdout=subprocess.PIPE,
+                                    stderr=subprocess.PIPE)
+            proc.communicate()
+
+            return (proc.returncode == 0) == should_succeed
+
+        caller = test_utils.find_test_caller()
+        LOG.debug('%(caller)s begins to ping %(ip)s in %(timeout)s sec and the'
+                  ' expected result is %(should_succeed)s', {
+                      'caller': caller, 'ip': ip_address, 'timeout': timeout,
+                      'should_succeed':
+                      'reachable' if should_succeed else 'unreachable'
+                  })
+        result = test_utils.call_until_true(ping, timeout, 1)
+        LOG.debug('%(caller)s finishes ping %(ip)s in %(timeout)s sec and the '
+                  'ping result is %(result)s', {
+                      'caller': caller, 'ip': ip_address, 'timeout': timeout,
+                      'result': 'expected' if result else 'unexpected'
+                  })
+        return result
+
+    def check_vm_connectivity(self, ip_address,
+                              username=None,
+                              private_key=None,
+                              should_connect=True,
+                              mtu=None):
+        """Check server connectivity
+
+        :param ip_address: server to test against
+        :param username: server's ssh username
+        :param private_key: server's ssh private key to be used
+        :param should_connect: True/False indicates positive/negative test
+            positive - attempt ping and ssh
+            negative - attempt ping and fail if succeed
+        :param mtu: network MTU to use for connectivity validation
+
+        :raises: AssertError if the result of the connectivity check does
+            not match the value of the should_connect param
+        """
+        if should_connect:
+            msg = "Timed out waiting for %s to become reachable" % ip_address
+        else:
+            msg = "ip address %s is reachable" % ip_address
+        self.assertTrue(self.ping_ip_address(ip_address,
+                                             should_succeed=should_connect,
+                                             mtu=mtu),
+                        msg=msg)
+        if should_connect:
+            # no need to check ssh for negative connectivity
+            self.get_remote_client(ip_address, username, private_key)
+
+    def create_floating_ip(self, thing, pool_name=None):
+        """Create a floating IP and associates to a server on Nova"""
+
+        if not pool_name:
+            pool_name = CONF.network.floating_network_name
+        floating_ip = (self.compute_floating_ips_client.
+                       create_floating_ip(pool=pool_name)['floating_ip'])
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        self.compute_floating_ips_client.delete_floating_ip,
+                        floating_ip['id'])
+        self.compute_floating_ips_client.associate_floating_ip_to_server(
+            floating_ip['ip'], thing['id'])
+        return floating_ip
+
+    def create_timestamp(self, ip_address, dev_name=None, mount_path='/mnt',
+                         private_key=None):
+        ssh_client = self.get_remote_client(ip_address,
+                                            private_key=private_key)
+        if dev_name is not None:
+            ssh_client.make_fs(dev_name)
+            ssh_client.exec_command('sudo mount /dev/%s %s' % (dev_name,
+                                                               mount_path))
+        cmd_timestamp = 'sudo sh -c "date > %s/timestamp; sync"' % mount_path
+        ssh_client.exec_command(cmd_timestamp)
+        timestamp = ssh_client.exec_command('sudo cat %s/timestamp'
+                                            % mount_path)
+        if dev_name is not None:
+            ssh_client.exec_command('sudo umount %s' % mount_path)
+        return timestamp
+
+    def get_server_ip(self, server):
+        """Get the server fixed or floating IP.
+
+        Based on the configuration we're in, return a correct ip
+        address for validating that a guest is up.
+        """
+        if CONF.validation.connect_method == 'floating':
+            # The tests calling this method don't have a floating IP
+            # and can't make use of the validation resources. So the
+            # method is creating the floating IP there.
+            return self.create_floating_ip(server)['ip']
+        elif CONF.validation.connect_method == 'fixed':
+            # Determine the network name to look for based on config or creds
+            # provider network resources.
+            if CONF.validation.network_for_ssh:
+                addresses = server['addresses'][
+                    CONF.validation.network_for_ssh]
+            else:
+                creds_provider = self._get_credentials_provider()
+                net_creds = creds_provider.get_primary_creds()
+                network = getattr(net_creds, 'network', None)
+                addresses = (server['addresses'][network['name']]
+                             if network else [])
+            for address in addresses:
+                if (address['version'] == CONF.validation.ip_version_for_ssh
+                        and address['OS-EXT-IPS:type'] == 'fixed'):
+                    return address['addr']
+            raise exceptions.ServerUnreachable(server_id=server['id'])
+        else:
+            raise lib_exc.InvalidConfiguration()
+
+    def _get_router(self, client=None, tenant_id=None):
+        """Retrieve a router for the given tenant id.
+
+        If a public router has been configured, it will be returned.
+
+        If a public router has not been configured, but a public
+        network has, a tenant router will be created and returned that
+        routes traffic to the public network.
+        """
+        if not client:
+            client = self.routers_client
+        if not tenant_id:
+            tenant_id = client.tenant_id
+        router_id = CONF.network.public_router_id
+        network_id = CONF.network.public_network_id
+        if router_id:
+            body = client.show_router(router_id)
+            return body['router']
+        elif network_id:
+            router = self._create_router(client, tenant_id)
+            kwargs = {'external_gateway_info': dict(network_id=network_id)}
+            router = client.update_router(router['id'], **kwargs)['router']
+            return router
+        else:
+            raise Exception("Neither of 'public_router_id' or "
+                            "'public_network_id' has been defined.")
+
+    def _create_router(self, client=None, tenant_id=None,
+                       namestart='router-smoke'):
+        if not client:
+            client = self.routers_client
+        if not tenant_id:
+            tenant_id = client.tenant_id
+        name = data_utils.rand_name(namestart)
+        result = client.create_router(name=name,
+                                      admin_state_up=True,
+                                      tenant_id=tenant_id)
+        router = result['router']
+        self.assertEqual(router['name'], name)
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        client.delete_router,
+                        router['id'])
+        return router
+
+
+class NetworkScenarioTest(ScenarioTest):
+    """Base class for network scenario tests.
+
+    This class provide helpers for network scenario tests, using the neutron
+    API. Helpers from ancestor which use the nova network API are overridden
+    with the neutron API.
+
+    This Class also enforces using Neutron instead of novanetwork.
+    Subclassed tests will be skipped if Neutron is not enabled
+
+    """
+
+    credentials = ['primary', 'admin']
+
+    @classmethod
+    def skip_checks(cls):
+        super(NetworkScenarioTest, cls).skip_checks()
+        if not CONF.service_available.neutron:
+            raise cls.skipException('Neutron not available')
+
+    def _create_network(self, networks_client=None,
+                        tenant_id=None,
+                        namestart='network-smoke-',
+                        port_security_enabled=True):
+        if not networks_client:
+            networks_client = self.networks_client
+        if not tenant_id:
+            tenant_id = networks_client.tenant_id
+        name = data_utils.rand_name(namestart)
+        network_kwargs = dict(name=name, tenant_id=tenant_id)
+        # Neutron disables port security by default so we have to check the
+        # config before trying to create the network with port_security_enabled
+        if CONF.network_feature_enabled.port_security:
+            network_kwargs['port_security_enabled'] = port_security_enabled
+        result = networks_client.create_network(**network_kwargs)
+        network = result['network']
+
+        self.assertEqual(network['name'], name)
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        networks_client.delete_network,
+                        network['id'])
+        return network
+
+    def _get_server_port_id_and_ip4(self, server, ip_addr=None):
+        ports = self.os_admin.ports_client.list_ports(
+            device_id=server['id'], fixed_ip=ip_addr)['ports']
+        # A port can have more than one IP address in some cases.
+        # If the network is dual-stack (IPv4 + IPv6), this port is associated
+        # with 2 subnets
+        p_status = ['ACTIVE']
+        # NOTE(vsaienko) With Ironic, instances live on separate hardware
+        # servers. Neutron does not bind ports for Ironic instances, as a
+        # result the port remains in the DOWN state.
+        # TODO(vsaienko) remove once bug: #1599836 is resolved.
+        if getattr(CONF.service_available, 'ironic', False):
+            p_status.append('DOWN')
+        port_map = [(p["id"], fxip["ip_address"])
+                    for p in ports
+                    for fxip in p["fixed_ips"]
+                    if netutils.is_valid_ipv4(fxip["ip_address"])
+                    and p['status'] in p_status]
+        inactive = [p for p in ports if p['status'] != 'ACTIVE']
+        if inactive:
+            LOG.warning("Instance has ports that are not ACTIVE: %s", inactive)
+
+        self.assertNotEqual(0, len(port_map),
+                            "No IPv4 addresses found in: %s" % ports)
+        self.assertEqual(len(port_map), 1,
+                         "Found multiple IPv4 addresses: %s. "
+                         "Unable to determine which port to target."
+                         % port_map)
+        return port_map[0]
+
+    def create_floating_ip(self, thing, external_network_id=None,
+                           port_id=None, client=None):
+        """Create a floating IP and associates to a resource/port on Neutron"""
+        if not external_network_id:
+            external_network_id = CONF.network.public_network_id
+        if not client:
+            client = self.floating_ips_client
+        if not port_id:
+            port_id, ip4 = self._get_server_port_id_and_ip4(thing)
+        else:
+            ip4 = None
+        result = client.create_floatingip(
+            floating_network_id=external_network_id,
+            port_id=port_id,
+            tenant_id=thing['tenant_id'],
+            fixed_ip_address=ip4
+        )
+        floating_ip = result['floatingip']
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        client.delete_floatingip,
+                        floating_ip['id'])
+        return floating_ip
diff --git a/ironic_tempest_plugin/plugin.py b/ironic_tempest_plugin/plugin.py
index 0428c7d..e20386b 100644
--- a/ironic_tempest_plugin/plugin.py
+++ b/ironic_tempest_plugin/plugin.py
@@ -1,3 +1,6 @@
+# Copyright 2015 NEC Corporation
+# All Rights Reserved.
+#
 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
 #    not use this file except in compliance with the License. You may obtain
 #    a copy of the License at
@@ -13,29 +16,35 @@
 
 import os
 
+from tempest import config
 from tempest.test_discover import plugins
 
-from ironic_inspector.test.inspector_tempest_plugin import config
+from ironic_tempest_plugin import config as project_config
+
+_opts = [
+    (project_config.baremetal_group, project_config.BaremetalGroup),
+    (project_config.baremetal_features_group,
+     project_config.BaremetalFeaturesGroup)
+    (project_config.baremetal_introspection_group,
+     project_config.BaremetalIntrospectionGroup),
+]
 
 
-class InspectorTempestPlugin(plugins.TempestPlugin):
+class IronicTempestPlugin(plugins.TempestPlugin):
     def load_tests(self):
         base_path = os.path.split(os.path.dirname(
             os.path.abspath(__file__)))[0]
-        test_dir = "inspector_tempest_plugin/tests"
+        test_dir = "ironic_tempest_plugin/tests"
         full_test_dir = os.path.join(base_path, test_dir)
         return full_test_dir, base_path
 
     def register_opts(self, conf):
-        conf.register_opt(config.service_option,
+        conf.register_opt(project_config.ironic_service_option,
                           group='service_available')
-        conf.register_group(config.baremetal_introspection_group)
-        conf.register_opts(config.BaremetalIntrospectionGroup,
-                           group="baremetal_introspection")
+        conf.register_opt(project_config.inspector_service_option,
+                          group='service_available')
+        for group, option in _opts:
+            config.register_opt_group(conf, group, option)
 
     def get_opt_lists(self):
-        return [
-            (config.baremetal_introspection_group.name,
-             config.BaremetalIntrospectionGroup),
-            ('service_available', [config.service_option])
-        ]
+        return [(group.name, option) for group, option in _opts]
diff --git a/ironic_tempest_plugin/services/baremetal/__init__.py b/ironic_tempest_plugin/services/baremetal/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/services/baremetal/__init__.py
diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py
new file mode 100644
index 0000000..757a770
--- /dev/null
+++ b/ironic_tempest_plugin/services/baremetal/base.py
@@ -0,0 +1,264 @@
+#    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 functools
+
+from oslo_serialization import jsonutils as json
+from six.moves import http_client
+from six.moves.urllib import parse as urllib
+from tempest.lib.common import api_version_utils
+from tempest.lib.common import rest_client
+
+# NOTE(vsaienko): concurrent tests work because they are launched in
+# separate processes so global variables are not shared among them.
+BAREMETAL_MICROVERSION = None
+
+
+def set_baremetal_api_microversion(baremetal_microversion):
+    global BAREMETAL_MICROVERSION
+    BAREMETAL_MICROVERSION = baremetal_microversion
+
+
+def reset_baremetal_api_microversion():
+    global BAREMETAL_MICROVERSION
+    BAREMETAL_MICROVERSION = None
+
+
+def handle_errors(f):
+    """A decorator that allows to ignore certain types of errors."""
+
+    @functools.wraps(f)
+    def wrapper(*args, **kwargs):
+        param_name = 'ignore_errors'
+        ignored_errors = kwargs.get(param_name, tuple())
+
+        if param_name in kwargs:
+            del kwargs[param_name]
+
+        try:
+            return f(*args, **kwargs)
+        except ignored_errors:
+            # Silently ignore errors
+            pass
+
+    return wrapper
+
+
+class BaremetalClient(rest_client.RestClient):
+    """Base Tempest REST client for Ironic API."""
+
+    api_microversion_header_name = 'X-OpenStack-Ironic-API-Version'
+    uri_prefix = ''
+
+    def get_headers(self):
+        headers = super(BaremetalClient, self).get_headers()
+        if BAREMETAL_MICROVERSION:
+            headers[self.api_microversion_header_name] = BAREMETAL_MICROVERSION
+        return headers
+
+    def request(self, *args, **kwargs):
+        resp, resp_body = super(BaremetalClient, self).request(*args, **kwargs)
+        if (BAREMETAL_MICROVERSION and
+            BAREMETAL_MICROVERSION != api_version_utils.LATEST_MICROVERSION):
+            api_version_utils.assert_version_header_matches_request(
+                self.api_microversion_header_name,
+                BAREMETAL_MICROVERSION,
+                resp)
+        return resp, resp_body
+
+    def serialize(self, object_dict):
+        """Serialize an Ironic object."""
+
+        return json.dumps(object_dict)
+
+    def deserialize(self, object_str):
+        """Deserialize an Ironic object."""
+
+        return json.loads(object_str)
+
+    def _get_uri(self, resource_name, uuid=None, permanent=False):
+        """Get URI for a specific resource or object.
+
+        :param resource_name: The name of the REST resource, e.g., 'nodes'.
+        :param uuid: The unique identifier of an object in UUID format.
+        :returns: Relative URI for the resource or object.
+
+        """
+        prefix = self.uri_prefix if not permanent else ''
+
+        return '{pref}/{res}{uuid}'.format(pref=prefix,
+                                           res=resource_name,
+                                           uuid='/%s' % uuid if uuid else '')
+
+    def _make_patch(self, allowed_attributes, **kwargs):
+        """Create a JSON patch according to RFC 6902.
+
+        :param allowed_attributes: An iterable object that contains a set of
+            allowed attributes for an object.
+        :param **kwargs: Attributes and new values for them.
+        :returns: A JSON path that sets values of the specified attributes to
+            the new ones.
+
+        """
+        def get_change(kwargs, path='/'):
+            for name, value in kwargs.items():
+                if isinstance(value, dict):
+                    for ch in get_change(value, path + '%s/' % name):
+                        yield ch
+                else:
+                    if value is None:
+                        yield {'path': path + name,
+                               'op': 'remove'}
+                    else:
+                        yield {'path': path + name,
+                               'value': value,
+                               'op': 'replace'}
+
+        patch = [ch for ch in get_change(kwargs)
+                 if ch['path'].lstrip('/') in allowed_attributes]
+
+        return patch
+
+    def _list_request(self, resource, permanent=False, headers=None,
+                      extra_headers=False, **kwargs):
+        """Get the list of objects of the specified type.
+
+        :param resource: The name of the REST resource, e.g., 'nodes'.
+        :param headers: List of headers to use in request.
+        :param extra_headers: Specify whether to use headers.
+        :param **kwargs: Parameters for the request.
+        :returns: A tuple with the server response and deserialized JSON list
+                 of objects
+
+        """
+        uri = self._get_uri(resource, permanent=permanent)
+        if kwargs:
+            uri += "?%s" % urllib.urlencode(kwargs)
+
+        resp, body = self.get(uri, headers=headers,
+                              extra_headers=extra_headers)
+        self.expected_success(http_client.OK, resp.status)
+
+        return resp, self.deserialize(body)
+
+    def _show_request(self,
+                      resource,
+                      uuid=None,
+                      permanent=False,
+                      **kwargs):
+        """Gets a specific object of the specified type.
+
+        :param uuid: Unique identifier of the object in UUID format.
+        :returns: Serialized object as a dictionary.
+
+        """
+        if 'uri' in kwargs:
+            uri = kwargs['uri']
+        else:
+            uri = self._get_uri(resource, uuid=uuid, permanent=permanent)
+        resp, body = self.get(uri)
+        self.expected_success(http_client.OK, resp.status)
+
+        return resp, self.deserialize(body)
+
+    def _create_request(self, resource, object_dict):
+        """Create an object of the specified type.
+
+        :param resource: The name of the REST resource, e.g., 'nodes'.
+        :param object_dict: A Python dict that represents an object of the
+                            specified type.
+        :returns: A tuple with the server response and the deserialized created
+                 object.
+
+        """
+        body = self.serialize(object_dict)
+        uri = self._get_uri(resource)
+
+        resp, body = self.post(uri, body=body)
+        self.expected_success(http_client.CREATED, resp.status)
+
+        return resp, self.deserialize(body)
+
+    def _create_request_no_response_body(self, resource, object_dict):
+        """Create an object of the specified type.
+
+           Do not expect any body in the response.
+
+        :param resource: The name of the REST resource, e.g., 'nodes'.
+        :param object_dict: A Python dict that represents an object of the
+                            specified type.
+        :returns: The server response.
+        """
+
+        body = self.serialize(object_dict)
+        uri = self._get_uri(resource)
+
+        resp, body = self.post(uri, body=body)
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+
+        return resp
+
+    def _delete_request(self, resource, uuid):
+        """Delete specified object.
+
+        :param resource: The name of the REST resource, e.g., 'nodes'.
+        :param uuid: The unique identifier of an object in UUID format.
+        :returns: A tuple with the server response and the response body.
+
+        """
+        uri = self._get_uri(resource, uuid)
+
+        resp, body = self.delete(uri)
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
+
+    def _patch_request(self, resource, uuid, patch_object):
+        """Update specified object with JSON-patch.
+
+        :param resource: The name of the REST resource, e.g., 'nodes'.
+        :param uuid: The unique identifier of an object in UUID format.
+        :returns: A tuple with the server response and the serialized patched
+                 object.
+
+        """
+        uri = self._get_uri(resource, uuid)
+        patch_body = json.dumps(patch_object)
+
+        resp, body = self.patch(uri, body=patch_body)
+        self.expected_success(http_client.OK, resp.status)
+        return resp, self.deserialize(body)
+
+    @handle_errors
+    def get_api_description(self):
+        """Retrieves all versions of the Ironic API."""
+
+        return self._list_request('', permanent=True)
+
+    @handle_errors
+    def get_version_description(self, version='v1'):
+        """Retrieves the description of the API.
+
+        :param version: The version of the API. Default: 'v1'.
+        :returns: Serialized description of API resources.
+
+        """
+        return self._list_request(version, permanent=True)
+
+    def _put_request(self, resource, put_object):
+        """Update specified object with JSON-patch."""
+        uri = self._get_uri(resource)
+        put_body = json.dumps(put_object)
+
+        resp, body = self.put(uri, body=put_body)
+        self.expected_success([http_client.ACCEPTED, http_client.NO_CONTENT],
+                              resp.status)
+        return resp, body
diff --git a/ironic_tempest_plugin/services/baremetal/v1/__init__.py b/ironic_tempest_plugin/services/baremetal/v1/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/services/baremetal/v1/__init__.py
diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/__init__.py b/ironic_tempest_plugin/services/baremetal/v1/json/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/__init__.py
diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
new file mode 100644
index 0000000..9712dba
--- /dev/null
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -0,0 +1,641 @@
+#    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 six.moves import http_client
+
+from ironic_tempest_plugin.services.baremetal import base
+
+
+class BaremetalClient(base.BaremetalClient):
+    """Base Tempest REST client for Ironic API v1."""
+    version = '1'
+    uri_prefix = 'v1'
+
+    @base.handle_errors
+    def list_nodes(self, **kwargs):
+        """List all existing nodes."""
+        return self._list_request('nodes', **kwargs)
+
+    @base.handle_errors
+    def list_nodes_detail(self, **kwargs):
+        """Detailed list of all existing nodes."""
+        return self._list_request('/nodes/detail', **kwargs)
+
+    @base.handle_errors
+    def list_chassis(self):
+        """List all existing chassis."""
+        return self._list_request('chassis')
+
+    @base.handle_errors
+    def list_chassis_nodes(self, chassis_uuid):
+        """List all nodes associated with a chassis."""
+        return self._list_request('/chassis/%s/nodes' % chassis_uuid)
+
+    @base.handle_errors
+    def list_ports(self, **kwargs):
+        """List all existing ports."""
+        return self._list_request('ports', **kwargs)
+
+    @base.handle_errors
+    def list_portgroups(self, **kwargs):
+        """List all existing port groups."""
+        return self._list_request('portgroups', **kwargs)
+
+    @base.handle_errors
+    def list_volume_connectors(self, **kwargs):
+        """List all existing volume connectors."""
+        return self._list_request('volume/connectors', **kwargs)
+
+    @base.handle_errors
+    def list_volume_targets(self, **kwargs):
+        """List all existing volume targets."""
+        return self._list_request('volume/targets', **kwargs)
+
+    @base.handle_errors
+    def list_node_ports(self, uuid):
+        """List all ports associated with the node."""
+        return self._list_request('/nodes/%s/ports' % uuid)
+
+    @base.handle_errors
+    def list_nodestates(self, uuid):
+        """List all existing states."""
+        return self._list_request('/nodes/%s/states' % uuid)
+
+    @base.handle_errors
+    def list_ports_detail(self, **kwargs):
+        """Details list all existing ports."""
+        return self._list_request('/ports/detail', **kwargs)
+
+    @base.handle_errors
+    def list_drivers(self):
+        """List all existing drivers."""
+        return self._list_request('drivers')
+
+    @base.handle_errors
+    def show_node(self, uuid):
+        """Gets a specific node.
+
+        :param uuid: Unique identifier of the node in UUID format.
+        :return: Serialized node as a dictionary.
+
+        """
+        return self._show_request('nodes', uuid)
+
+    @base.handle_errors
+    def show_node_by_instance_uuid(self, instance_uuid):
+        """Gets a node associated with given instance uuid.
+
+        :param instance_uuid: Unique identifier of the instance in UUID format.
+        :return: Serialized node as a dictionary.
+
+        """
+        uri = '/nodes/detail?instance_uuid=%s' % instance_uuid
+
+        return self._show_request('nodes',
+                                  uuid=None,
+                                  uri=uri)
+
+    @base.handle_errors
+    def show_chassis(self, uuid):
+        """Gets a specific chassis.
+
+        :param uuid: Unique identifier of the chassis in UUID format.
+        :return: Serialized chassis as a dictionary.
+
+        """
+        return self._show_request('chassis', uuid)
+
+    @base.handle_errors
+    def show_port(self, uuid):
+        """Gets a specific port.
+
+        :param uuid: Unique identifier of the port in UUID format.
+        :return: Serialized port as a dictionary.
+
+        """
+        return self._show_request('ports', uuid)
+
+    @base.handle_errors
+    def show_portgroup(self, portgroup_ident):
+        """Gets a specific port group.
+
+        :param portgroup_ident: Name or UUID of the port group.
+        :return: Serialized port group as a dictionary.
+        """
+        return self._show_request('portgroups', portgroup_ident)
+
+    @base.handle_errors
+    def show_volume_connector(self, volume_connector_ident):
+        """Gets a specific volume connector.
+
+        :param volume_connector_ident: UUID of the volume connector.
+        :return: Serialized volume connector as a dictionary.
+        """
+        return self._show_request('volume/connectors', volume_connector_ident)
+
+    @base.handle_errors
+    def show_volume_target(self, volume_target_ident):
+        """Gets a specific volume target.
+
+        :param volume_target_ident: UUID of the volume target.
+        :return: Serialized volume target as a dictionary.
+        """
+        return self._show_request('volume/targets', volume_target_ident)
+
+    @base.handle_errors
+    def show_port_by_address(self, address):
+        """Gets a specific port by address.
+
+        :param address: MAC address of the port.
+        :return: Serialized port as a dictionary.
+
+        """
+        uri = '/ports/detail?address=%s' % address
+
+        return self._show_request('ports', uuid=None, uri=uri)
+
+    def show_driver(self, driver_name):
+        """Gets a specific driver.
+
+        :param driver_name: Name of driver.
+        :return: Serialized driver as a dictionary.
+        """
+        return self._show_request('drivers', driver_name)
+
+    @base.handle_errors
+    def create_node(self, chassis_id=None, **kwargs):
+        """Create a baremetal node with the specified parameters.
+
+        :param chassis_id: The unique identifier of the chassis.
+        :param cpu_arch: CPU architecture of the node. Default: x86_64.
+        :param cpus: Number of CPUs. Default: 8.
+        :param local_gb: Disk size. Default: 1024.
+        :param memory_mb: Available RAM. Default: 4096.
+        :param driver: Driver name. Default: "fake"
+        :return: A tuple with the server response and the created node.
+
+        """
+        node = {}
+        if kwargs.get('resource_class'):
+            node['resource_class'] = kwargs['resource_class']
+
+        node.update(
+            {'chassis_uuid': chassis_id,
+             'properties': {'cpu_arch': kwargs.get('cpu_arch', 'x86_64'),
+                            'cpus': kwargs.get('cpus', 8),
+                            'local_gb': kwargs.get('local_gb', 1024),
+                            'memory_mb': kwargs.get('memory_mb', 4096)},
+             'driver': kwargs.get('driver', 'fake')}
+        )
+
+        return self._create_request('nodes', node)
+
+    @base.handle_errors
+    def create_chassis(self, **kwargs):
+        """Create a chassis with the specified parameters.
+
+        :param description: The description of the chassis.
+            Default: test-chassis
+        :return: A tuple with the server response and the created chassis.
+
+        """
+        chassis = {'description': kwargs.get('description', 'test-chassis')}
+
+        if 'uuid' in kwargs:
+            chassis.update({'uuid': kwargs.get('uuid')})
+
+        return self._create_request('chassis', chassis)
+
+    @base.handle_errors
+    def create_port(self, node_id, **kwargs):
+        """Create a port with the specified parameters.
+
+        :param node_id: The ID of the node which owns the port.
+        :param address: MAC address of the port.
+        :param extra: Meta data of the port. Default: {'foo': 'bar'}.
+        :param uuid: UUID of the port.
+        :param portgroup_uuid: The UUID of a portgroup of which this port is a
+            member.
+        :param physical_network: The physical network to which the port is
+            attached.
+        :return: A tuple with the server response and the created port.
+
+        """
+        port = {'extra': kwargs.get('extra', {'foo': 'bar'}),
+                'uuid': kwargs['uuid']}
+
+        if node_id is not None:
+            port['node_uuid'] = node_id
+
+        for key in ('address', 'physical_network', 'portgroup_uuid'):
+            if kwargs.get(key) is not None:
+                port[key] = kwargs[key]
+
+        return self._create_request('ports', port)
+
+    @base.handle_errors
+    def create_portgroup(self, node_uuid, **kwargs):
+        """Create a port group with the specified parameters.
+
+        :param node_uuid: The UUID of the node which owns the port group.
+        :param kwargs:
+            address: MAC address of the port group. Optional.
+            extra: Meta data of the port group. Default: {'foo': 'bar'}.
+            name: Name of the port group. Optional.
+            uuid: UUID of the port group. Optional.
+        :return: A tuple with the server response and the created port group.
+        """
+        portgroup = {'extra': kwargs.get('extra', {'foo': 'bar'})}
+
+        portgroup['node_uuid'] = node_uuid
+
+        if kwargs.get('address'):
+            portgroup['address'] = kwargs['address']
+
+        if kwargs.get('name'):
+            portgroup['name'] = kwargs['name']
+
+        return self._create_request('portgroups', portgroup)
+
+    @base.handle_errors
+    def create_volume_connector(self, node_uuid, **kwargs):
+        """Create a volume connector with the specified parameters.
+
+        :param node_uuid: The UUID of the node which owns the volume connector.
+        :param kwargs:
+            type: type of the volume connector.
+            connector_id: connector_id of the volume connector.
+            uuid: UUID of the volume connector. Optional.
+            extra: meta data of the volume connector; a dictionary. Optional.
+        :return: A tuple with the server response and the created volume
+            connector.
+        """
+        volume_connector = {'node_uuid': node_uuid}
+
+        for arg in ('type', 'connector_id', 'uuid', 'extra'):
+            if arg in kwargs:
+                volume_connector[arg] = kwargs[arg]
+
+        return self._create_request('volume/connectors', volume_connector)
+
+    @base.handle_errors
+    def create_volume_target(self, node_uuid, **kwargs):
+        """Create a volume target with the specified parameters.
+
+        :param node_uuid: The UUID of the node which owns the volume target.
+        :param kwargs:
+            volume_type: type of the volume target.
+            volume_id: volume_id of the volume target.
+            boot_index: boot index of the volume target.
+            uuid: UUID of the volume target. Optional.
+            extra: meta data of the volume target; a dictionary. Optional.
+            properties: properties related to the type of the volume target;
+                a dictionary. Optional.
+        :return: A tuple with the server response and the created volume
+            target.
+        """
+        volume_target = {'node_uuid': node_uuid}
+
+        for arg in ('volume_type', 'volume_id', 'boot_index', 'uuid', 'extra',
+                    'properties'):
+            if arg in kwargs:
+                volume_target[arg] = kwargs[arg]
+
+        return self._create_request('volume/targets', volume_target)
+
+    @base.handle_errors
+    def delete_node(self, uuid):
+        """Deletes a node having the specified UUID.
+
+        :param uuid: The unique identifier of the node.
+        :return: A tuple with the server response and the response body.
+
+        """
+        return self._delete_request('nodes', uuid)
+
+    @base.handle_errors
+    def delete_chassis(self, uuid):
+        """Deletes a chassis having the specified UUID.
+
+        :param uuid: The unique identifier of the chassis.
+        :return: A tuple with the server response and the response body.
+
+        """
+        return self._delete_request('chassis', uuid)
+
+    @base.handle_errors
+    def delete_port(self, uuid):
+        """Deletes a port having the specified UUID.
+
+        :param uuid: The unique identifier of the port.
+        :return: A tuple with the server response and the response body.
+
+        """
+        return self._delete_request('ports', uuid)
+
+    @base.handle_errors
+    def delete_portgroup(self, portgroup_ident):
+        """Deletes a port group having the specified UUID or name.
+
+        :param portgroup_ident: Name or UUID of the port group.
+        :return: A tuple with the server response and the response body.
+        """
+        return self._delete_request('portgroups', portgroup_ident)
+
+    @base.handle_errors
+    def delete_volume_connector(self, volume_connector_ident):
+        """Deletes a volume connector having the specified UUID.
+
+        :param volume_connector_ident: UUID of the volume connector.
+        :return: A tuple with the server response and the response body.
+        """
+        return self._delete_request('volume/connectors',
+                                    volume_connector_ident)
+
+    @base.handle_errors
+    def delete_volume_target(self, volume_target_ident):
+        """Deletes a volume target having the specified UUID.
+
+        :param volume_target_ident: UUID of the volume target.
+        :return: A tuple with the server response and the response body.
+        """
+        return self._delete_request('volume/targets', volume_target_ident)
+
+    @base.handle_errors
+    def update_node(self, uuid, patch=None, **kwargs):
+        """Update the specified node.
+
+        :param uuid: The unique identifier of the node.
+        :param patch: A JSON path that sets values of the specified attributes
+                      to the new ones.
+        :param **kwargs: Attributes and new values for them, used only when
+                         patch param is not set.
+        :return: A tuple with the server response and the updated node.
+
+        """
+        node_attributes = ('properties/cpu_arch',
+                           'properties/cpus',
+                           'properties/local_gb',
+                           'properties/memory_mb',
+                           'driver',
+                           'instance_uuid',
+                           'resource_class')
+        if not patch:
+            patch = self._make_patch(node_attributes, **kwargs)
+
+        return self._patch_request('nodes', uuid, patch)
+
+    @base.handle_errors
+    def update_chassis(self, uuid, **kwargs):
+        """Update the specified chassis.
+
+        :param uuid: The unique identifier of the chassis.
+        :return: A tuple with the server response and the updated chassis.
+
+        """
+        chassis_attributes = ('description',)
+        patch = self._make_patch(chassis_attributes, **kwargs)
+
+        return self._patch_request('chassis', uuid, patch)
+
+    @base.handle_errors
+    def update_port(self, uuid, patch):
+        """Update the specified port.
+
+        :param uuid: The unique identifier of the port.
+        :param patch: List of dicts representing json patches.
+        :return: A tuple with the server response and the updated port.
+
+        """
+
+        return self._patch_request('ports', uuid, patch)
+
+    @base.handle_errors
+    def update_volume_connector(self, uuid, patch):
+        """Update the specified volume connector.
+
+        :param uuid: The unique identifier of the volume connector.
+        :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 volume
+            connector.
+        """
+
+        return self._patch_request('volume/connectors', uuid, patch)
+
+    @base.handle_errors
+    def update_volume_target(self, uuid, patch):
+        """Update the specified volume target.
+
+        :param uuid: The unique identifier of the volume target.
+        :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 volume
+            target.
+        """
+
+        return self._patch_request('volume/targets', uuid, patch)
+
+    @base.handle_errors
+    def set_node_power_state(self, node_uuid, state):
+        """Set power state of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param state: desired state to set (on/off/reboot).
+
+        """
+        target = {'target': state}
+        return self._put_request('nodes/%s/states/power' % node_uuid,
+                                 target)
+
+    @base.handle_errors
+    def set_node_provision_state(self, node_uuid, state, configdrive=None,
+                                 clean_steps=None):
+        """Set provision state of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param state: desired state to set
+                (active/rebuild/deleted/inspect/manage/provide).
+        :param configdrive: A gzipped, base64-encoded
+            configuration drive string.
+        :param clean_steps: A list with clean steps to execute.
+        """
+        data = {'target': state}
+        # NOTE (vsaienk0): Add both here if specified, do not check anything.
+        # API will return an error in case of invalid parameters.
+        if configdrive is not None:
+            data['configdrive'] = configdrive
+        if clean_steps is not None:
+            data['clean_steps'] = clean_steps
+        return self._put_request('nodes/%s/states/provision' % node_uuid,
+                                 data)
+
+    @base.handle_errors
+    def set_node_raid_config(self, node_uuid, target_raid_config):
+        """Set raid config of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param target_raid_config: desired RAID configuration of the node.
+        """
+        return self._put_request('nodes/%s/states/raid' % node_uuid,
+                                 target_raid_config)
+
+    @base.handle_errors
+    def validate_driver_interface(self, node_uuid):
+        """Get all driver interfaces of a specific node.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+
+        """
+
+        uri = '{pref}/{res}/{uuid}/{postf}'.format(pref=self.uri_prefix,
+                                                   res='nodes',
+                                                   uuid=node_uuid,
+                                                   postf='validate')
+
+        return self._show_request('nodes', node_uuid, uri=uri)
+
+    @base.handle_errors
+    def set_node_boot_device(self, node_uuid, boot_device, persistent=False):
+        """Set the boot device of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :param boot_device: The boot device name.
+        :param persistent: Boolean value. True if the boot device will
+                           persist to all future boots, False if not.
+                           Default: False.
+
+        """
+        request = {'boot_device': boot_device, 'persistent': persistent}
+        resp, body = self._put_request('nodes/%s/management/boot_device' %
+                                       node_uuid, request)
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return body
+
+    @base.handle_errors
+    def get_node_boot_device(self, node_uuid):
+        """Get the current boot device of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+
+        """
+        path = 'nodes/%s/management/boot_device' % node_uuid
+        resp, body = self._list_request(path)
+        self.expected_success(http_client.OK, resp.status)
+        return body
+
+    @base.handle_errors
+    def get_node_supported_boot_devices(self, node_uuid):
+        """Get the supported boot devices of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+
+        """
+        path = 'nodes/%s/management/boot_device/supported' % node_uuid
+        resp, body = self._list_request(path)
+        self.expected_success(http_client.OK, resp.status)
+        return body
+
+    @base.handle_errors
+    def get_console(self, node_uuid):
+        """Get connection information about the console.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+
+        """
+
+        resp, body = self._show_request('nodes/states/console', node_uuid)
+        self.expected_success(http_client.OK, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def set_console_mode(self, node_uuid, enabled):
+        """Start and stop the node console.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        :param enabled: Boolean value; whether to enable or disable the
+                        console.
+
+        """
+
+        enabled = {'enabled': enabled}
+        resp, body = self._put_request('nodes/%s/states/console' % node_uuid,
+                                       enabled)
+        self.expected_success(http_client.ACCEPTED, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def vif_list(self, node_uuid, api_version=None):
+        """Get list of attached VIFs.
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        :param api_version: Ironic API version to use.
+        """
+        extra_headers = False
+        headers = None
+        if api_version is not None:
+            extra_headers = True
+            headers = {'x-openstack-ironic-api-version': api_version}
+        return self._list_request('nodes/%s/vifs' % node_uuid,
+                                  headers=headers,
+                                  extra_headers=extra_headers)
+
+    @base.handle_errors
+    def vif_attach(self, node_uuid, vif_id):
+        """Attach a VIF to a node
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        :param vif_id: An ID representing the VIF
+        """
+        vif = {'id': vif_id}
+        resp = self._create_request_no_response_body(
+            'nodes/%s/vifs' % node_uuid, vif)
+
+        return resp
+
+    @base.handle_errors
+    def vif_detach(self, node_uuid, vif_id):
+        """Detach a VIF from a node
+
+        :param node_uuid: Unique identifier of the node in UUID format.
+        :param vif_id: An ID representing the VIF
+        """
+        resp, body = self._delete_request('nodes/%s/vifs' % node_uuid, vif_id)
+        self.expected_success(http_client.NO_CONTENT, resp.status)
+        return resp, body
+
+    @base.handle_errors
+    def get_driver_properties(self, driver_name):
+        """Get properties information about driver.
+
+        :param driver_name: Name of driver.
+        :return: tuple of response and serialized properties as a dictionary.
+
+        """
+        uri = 'drivers/%s/properties' % driver_name
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        return resp, self.deserialize(body)
+
+    @base.handle_errors
+    def get_driver_logical_disk_properties(self, driver_name):
+        """Get driver logical disk properties.
+
+        :param driver_name: Name of driver.
+        :return: tuple of response and serialized logical disk properties as
+                 a dictionary.
+
+        """
+        uri = 'drivers/%s/raid/logical_disk_properties' % driver_name
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        return resp, self.deserialize(body)
diff --git a/ironic_tempest_plugin/tests/api/admin/__init__.py b/ironic_tempest_plugin/tests/api/admin/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/__init__.py
diff --git a/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py b/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py
new file mode 100644
index 0000000..ff7e09a
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py
@@ -0,0 +1,26 @@
+# 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 fixtures
+
+from ironic_tempest_plugin.services.baremetal import base
+
+
+class APIMicroversionFixture(fixtures.Fixture):
+
+    def __init__(self, baremetal_microversion):
+        self.baremetal_microversion = baremetal_microversion
+
+    def _setUp(self):
+        super(APIMicroversionFixture, self)._setUp()
+        base.set_baremetal_api_microversion(self.baremetal_microversion)
+        self.addCleanup(base.reset_baremetal_api_microversion)
diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py
new file mode 100644
index 0000000..2e7a4ff
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/base.py
@@ -0,0 +1,351 @@
+#    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 functools
+
+from tempest import config
+from tempest.lib.common import api_version_utils
+from tempest.lib.common.utils import data_utils
+from tempest.lib import exceptions as lib_exc
+from tempest import test
+
+from ironic_tempest_plugin import clients
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+
+CONF = config.CONF
+
+
+# NOTE(adam_g): The baremetal API tests exercise operations such as enroll
+# node, power on, power off, etc.  Testing against real drivers (ie, IPMI)
+# will require passing driver-specific data to Tempest (addresses,
+# credentials, etc).  Until then, only support testing against the fake driver,
+# which has no external dependencies.
+SUPPORTED_DRIVERS = ['fake']
+
+# 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', 'volume_connector', 'volume_target',
+                  'node', 'chassis']
+
+
+def creates(resource):
+    """Decorator that adds resources to the appropriate cleanup list."""
+
+    def decorator(f):
+        @functools.wraps(f)
+        def wrapper(cls, *args, **kwargs):
+            resp, body = f(cls, *args, **kwargs)
+
+            if 'uuid' in body:
+                cls.created_objects[resource].add(body['uuid'])
+
+            return resp, body
+        return wrapper
+    return decorator
+
+
+class BaseBaremetalTest(api_version_utils.BaseMicroversionTest,
+                        test.BaseTestCase):
+    """Base class for Baremetal API tests."""
+
+    credentials = ['admin']
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaseBaremetalTest, cls).skip_checks()
+        if not CONF.service_available.ironic:
+            raise cls.skipException('Ironic is not enabled.')
+        if CONF.baremetal.driver not in SUPPORTED_DRIVERS:
+            skip_msg = ('%s skipped as Ironic driver %s is not supported for '
+                        'testing.' %
+                        (cls.__name__, CONF.baremetal.driver))
+            raise cls.skipException(skip_msg)
+
+        cfg_min_version = CONF.baremetal.min_microversion
+        cfg_max_version = CONF.baremetal.max_microversion
+        api_version_utils.check_skip_with_microversion(cls.min_microversion,
+                                                       cls.max_microversion,
+                                                       cfg_min_version,
+                                                       cfg_max_version)
+
+    @classmethod
+    def setup_credentials(cls):
+        cls.request_microversion = (
+            api_version_utils.select_request_microversion(
+                cls.min_microversion,
+                CONF.baremetal.min_microversion))
+        cls.services_microversion = {
+            CONF.baremetal.catalog_type: cls.request_microversion}
+        super(BaseBaremetalTest, cls).setup_credentials()
+
+    @classmethod
+    def setup_clients(cls):
+        super(BaseBaremetalTest, cls).setup_clients()
+        cls.client = clients.Manager().baremetal_client
+
+    @classmethod
+    def resource_setup(cls):
+        super(BaseBaremetalTest, cls).resource_setup()
+        cls.request_microversion = (
+            api_version_utils.select_request_microversion(
+                cls.min_microversion,
+                CONF.baremetal.min_microversion))
+        cls.driver = CONF.baremetal.driver
+        cls.power_timeout = CONF.baremetal.power_timeout
+        cls.unprovision_timeout = CONF.baremetal.unprovision_timeout
+        cls.created_objects = {}
+        for resource in RESOURCE_TYPES:
+            cls.created_objects[resource] = set()
+
+    @classmethod
+    def resource_cleanup(cls):
+        """Ensure that all created objects get destroyed."""
+
+        try:
+            for resource in RESOURCE_TYPES:
+                uuids = cls.created_objects[resource]
+                delete_method = getattr(cls.client, 'delete_%s' % resource)
+                for u in uuids:
+                    delete_method(u, ignore_errors=lib_exc.NotFound)
+        finally:
+            super(BaseBaremetalTest, cls).resource_cleanup()
+
+    def _assertExpected(self, expected, actual):
+        """Check if expected keys/values exist in actual response body.
+
+        Check if the expected keys and values are in the actual response body.
+        It will not check the keys 'created_at' and 'updated_at', since they
+        will always have different values. Asserts if any expected key (or
+        corresponding value) is not in the actual response.
+
+        Note: this method has an underscore even though it is used outside of
+        this class, in order to distinguish this method from the more standard
+        assertXYZ methods.
+
+        :param expected: dict of key-value pairs that are expected to be in
+                         'actual' dict.
+        :param actual: dict of key-value pairs.
+
+        """
+        for key, value in expected.items():
+            if key not in ('created_at', 'updated_at'):
+                self.assertIn(key, actual)
+                self.assertEqual(value, actual[key])
+
+    def setUp(self):
+        super(BaseBaremetalTest, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture(
+            self.request_microversion))
+
+    @classmethod
+    @creates('chassis')
+    def create_chassis(cls, description=None, **kwargs):
+        """Wrapper utility for creating test chassis.
+
+        :param description: A description of the chassis. If not supplied,
+            a random value will be generated.
+        :return: A tuple with the server response and the created chassis.
+
+        """
+        description = description or data_utils.rand_name('test-chassis')
+        resp, body = cls.client.create_chassis(description=description,
+                                               **kwargs)
+        return resp, body
+
+    @classmethod
+    @creates('node')
+    def create_node(cls, chassis_id, cpu_arch='x86', cpus=8, local_gb=10,
+                    memory_mb=4096, resource_class=None):
+        """Wrapper utility for creating test baremetal nodes.
+
+        :param chassis_id: The unique identifier of the chassis.
+        :param cpu_arch: CPU architecture of the node. Default: x86.
+        :param cpus: Number of CPUs. Default: 8.
+        :param local_gb: Disk size. Default: 10.
+        :param memory_mb: Available RAM. Default: 4096.
+        :param resource_class: Node resource class.
+        :return: A tuple with the server response and the created node.
+
+        """
+        resp, body = cls.client.create_node(chassis_id, cpu_arch=cpu_arch,
+                                            cpus=cpus, local_gb=local_gb,
+                                            memory_mb=memory_mb,
+                                            driver=cls.driver,
+                                            resource_class=resource_class)
+
+        return resp, body
+
+    @classmethod
+    @creates('port')
+    def create_port(cls, node_id, address, extra=None, uuid=None,
+                    portgroup_uuid=None, physical_network=None):
+        """Wrapper utility for creating test ports.
+
+        :param node_id: The unique identifier of the node.
+        :param address: MAC address of the port.
+        :param extra: Meta data of the port. If not supplied, an empty
+            dictionary will be created.
+        :param uuid: UUID of the port.
+        :param portgroup_uuid: The UUID of a portgroup of which this port is a
+            member.
+        :param physical_network: The physical network to which the port is
+            attached.
+        :return: A tuple with the server response and the created port.
+
+        """
+        extra = extra or {}
+        resp, body = cls.client.create_port(address=address, node_id=node_id,
+                                            extra=extra, uuid=uuid,
+                                            portgroup_uuid=portgroup_uuid,
+                                            physical_network=physical_network)
+
+        return resp, body
+
+    @classmethod
+    @creates('portgroup')
+    def create_portgroup(cls, node_uuid, **kwargs):
+        """Wrapper utility for creating test port groups.
+
+        :param node_uuid: The unique identifier of the node.
+        :return: A tuple with the server response and the created port group.
+        """
+        resp, body = cls.client.create_portgroup(node_uuid=node_uuid, **kwargs)
+
+        return resp, body
+
+    @classmethod
+    @creates('volume_connector')
+    def create_volume_connector(cls, node_uuid, **kwargs):
+        """Wrapper utility for creating test volume connector.
+
+        :param node_uuid: The unique identifier of the node.
+        :return: A tuple with the server response and the created volume
+            connector.
+        """
+        resp, body = cls.client.create_volume_connector(node_uuid=node_uuid,
+                                                        **kwargs)
+
+        return resp, body
+
+    @classmethod
+    @creates('volume_target')
+    def create_volume_target(cls, node_uuid, **kwargs):
+        """Wrapper utility for creating test volume target.
+
+        :param node_uuid: The unique identifier of the node.
+        :return: A tuple with the server response and the created volume
+            target.
+        """
+        resp, body = cls.client.create_volume_target(node_uuid=node_uuid,
+                                                     **kwargs)
+
+        return resp, body
+
+    @classmethod
+    def delete_chassis(cls, chassis_id):
+        """Deletes a chassis having the specified UUID.
+
+        :param chassis_id: The unique identifier of the chassis.
+        :return: Server response.
+
+        """
+
+        resp, body = cls.client.delete_chassis(chassis_id)
+
+        if chassis_id in cls.created_objects['chassis']:
+            cls.created_objects['chassis'].remove(chassis_id)
+
+        return resp
+
+    @classmethod
+    def delete_node(cls, node_id):
+        """Deletes a node having the specified UUID.
+
+        :param node_id: The unique identifier of the node.
+        :return: Server response.
+
+        """
+
+        resp, body = cls.client.delete_node(node_id)
+
+        if node_id in cls.created_objects['node']:
+            cls.created_objects['node'].remove(node_id)
+
+        return resp
+
+    @classmethod
+    def delete_port(cls, port_id):
+        """Deletes a port having the specified UUID.
+
+        :param port_id: The unique identifier of the port.
+        :return: Server response.
+
+        """
+
+        resp, body = cls.client.delete_port(port_id)
+
+        if port_id in cls.created_objects['port']:
+            cls.created_objects['port'].remove(port_id)
+
+        return resp
+
+    @classmethod
+    def delete_portgroup(cls, portgroup_ident):
+        """Deletes a port group having the specified UUID or name.
+
+        :param portgroup_ident: The name or UUID of the port group.
+        :return: Server response.
+        """
+        resp, body = cls.client.delete_portgroup(portgroup_ident)
+
+        if portgroup_ident in cls.created_objects['portgroup']:
+            cls.created_objects['portgroup'].remove(portgroup_ident)
+
+        return resp
+
+    @classmethod
+    def delete_volume_connector(cls, volume_connector_id):
+        """Deletes a volume connector having the specified UUID.
+
+        :param volume_connector_id: The UUID of the volume connector.
+        :return: Server response.
+        """
+        resp, body = cls.client.delete_volume_connector(volume_connector_id)
+
+        if volume_connector_id in cls.created_objects['volume_connector']:
+            cls.created_objects['volume_connector'].remove(
+                volume_connector_id)
+
+        return resp
+
+    @classmethod
+    def delete_volume_target(cls, volume_target_id):
+        """Deletes a volume target having the specified UUID.
+
+        :param volume_target_id: The UUID of the volume target.
+        :return: Server response.
+        """
+        resp, body = cls.client.delete_volume_target(volume_target_id)
+
+        if volume_target_id in cls.created_objects['volume_target']:
+            cls.created_objects['volume_target'].remove(volume_target_id)
+
+        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(
+                        base=self.client.base_url.rstrip('/'),
+                        pref=self.client.uri_prefix,
+                        res=resource,
+                        uuid=uuid)
+        self.assertEqual(expected_link, link)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py b/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py
new file mode 100644
index 0000000..de2dada
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_api_discovery.py
@@ -0,0 +1,43 @@
+#    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 import decorators
+
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestApiDiscovery(base.BaseBaremetalTest):
+    """Tests for API discovery features."""
+
+    @decorators.idempotent_id('a3c27e94-f56c-42c4-8600-d6790650b9c5')
+    def test_api_versions(self):
+        _, descr = self.client.get_api_description()
+        expected_versions = ('v1',)
+        versions = [version['id'] for version in descr['versions']]
+
+        for v in expected_versions:
+            self.assertIn(v, versions)
+
+    @decorators.idempotent_id('896283a6-488e-4f31-af78-6614286cbe0d')
+    def test_default_version(self):
+        _, descr = self.client.get_api_description()
+        default_version = descr['default_version']
+        self.assertEqual('v1', default_version['id'])
+
+    @decorators.idempotent_id('abc0b34d-e684-4546-9728-ab7a9ad9f174')
+    def test_version_1_resources(self):
+        _, descr = self.client.get_version_description(version='v1')
+        expected_resources = ('nodes', 'chassis',
+                              'ports', 'links', 'media_types')
+
+        for res in expected_resources:
+            self.assertIn(res, descr)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_chassis.py b/ironic_tempest_plugin/tests/api/admin/test_chassis.py
new file mode 100644
index 0000000..19bdc13
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_chassis.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+#    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.admin import base
+
+
+class TestChassis(base.BaseBaremetalTest):
+    """Tests for chassis."""
+
+    @classmethod
+    def resource_setup(cls):
+        super(TestChassis, cls).resource_setup()
+        _, cls.chassis = cls.create_chassis()
+
+    @decorators.idempotent_id('7c5a2e09-699c-44be-89ed-2bc189992d42')
+    def test_create_chassis(self):
+        descr = data_utils.rand_name('test-chassis')
+        _, chassis = self.create_chassis(description=descr)
+        self.assertEqual(descr, chassis['description'])
+
+    @decorators.idempotent_id('cabe9c6f-dc16-41a7-b6b9-0a90c212edd5')
+    def test_create_chassis_unicode_description(self):
+        # Use a unicode string for testing:
+        # 'We ♡ OpenStack in Ukraine'
+        descr = u'В Україні ♡ OpenStack!'
+        _, chassis = self.create_chassis(description=descr)
+        self.assertEqual(descr, chassis['description'])
+
+    @decorators.idempotent_id('c84644df-31c4-49db-a307-8942881f41c0')
+    def test_show_chassis(self):
+        _, chassis = self.client.show_chassis(self.chassis['uuid'])
+        self._assertExpected(self.chassis, chassis)
+
+    @decorators.idempotent_id('29c9cd3f-19b5-417b-9864-99512c3b33b3')
+    def test_list_chassis(self):
+        _, body = self.client.list_chassis()
+        self.assertIn(self.chassis['uuid'],
+                      [i['uuid'] for i in body['chassis']])
+
+    @decorators.idempotent_id('5ae649ad-22d1-4fe1-bbc6-97227d199fb3')
+    def test_delete_chassis(self):
+        _, body = self.create_chassis()
+        uuid = body['uuid']
+
+        self.delete_chassis(uuid)
+        self.assertRaises(lib_exc.NotFound, self.client.show_chassis, uuid)
+
+    @decorators.idempotent_id('cda8a41f-6be2-4cbf-840c-994b00a89b44')
+    def test_update_chassis(self):
+        _, body = self.create_chassis()
+        uuid = body['uuid']
+
+        new_description = data_utils.rand_name('new-description')
+        _, body = (self.client.update_chassis(uuid,
+                   description=new_description))
+        _, chassis = self.client.show_chassis(uuid)
+        self.assertEqual(new_description, chassis['description'])
+
+    @decorators.idempotent_id('76305e22-a4e2-4ab3-855c-f4e2368b9335')
+    def test_chassis_node_list(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        _, body = self.client.list_chassis_nodes(self.chassis['uuid'])
+        self.assertIn(node['uuid'], [n['uuid'] for n in body['nodes']])
+
+    @decorators.idempotent_id('dd52bd5d-610c-4f2c-8fa3-d5e59269325f')
+    def test_create_chassis_uuid(self):
+        uuid = data_utils.rand_uuid()
+        _, chassis = self.create_chassis(uuid=uuid)
+        self.assertEqual(uuid, chassis['uuid'])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_drivers.py b/ironic_tempest_plugin/tests/api/admin/test_drivers.py
new file mode 100644
index 0000000..e80a963
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_drivers.py
@@ -0,0 +1,54 @@
+# Copyright 2014 NEC Corporation. All rights reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from tempest import config
+from tempest.lib import decorators
+
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+CONF = config.CONF
+
+
+class TestDrivers(base.BaseBaremetalTest):
+    """Tests for drivers."""
+
+    @classmethod
+    def resource_setup(cls):
+        super(TestDrivers, cls).resource_setup()
+        cls.driver_name = CONF.baremetal.driver
+
+    @decorators.idempotent_id('5aed2790-7592-4655-9b16-99abcc2e6ec5')
+    def test_list_drivers(self):
+        _, drivers = self.client.list_drivers()
+        self.assertIn(self.driver_name,
+                      [d['name'] for d in drivers['drivers']])
+
+    @decorators.idempotent_id('fb3287a3-c4d7-44bf-ae9d-1eef906d78ce')
+    def test_show_driver(self):
+        _, driver = self.client.show_driver(self.driver_name)
+        self.assertEqual(self.driver_name, driver['name'])
+
+    @decorators.idempotent_id('6efa976f-78a2-4859-b3aa-97d960d6e5e5')
+    def test_driver_properties(self):
+        _, properties = self.client.get_driver_properties(self.driver_name)
+        self.assertNotEmpty(properties)
+
+    @decorators.idempotent_id('fdf61f5a-f59d-4235-ad6c-cc718740e3e3')
+    def test_driver_logical_disk_properties(self):
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.12'))
+        _, properties = self.client.get_driver_logical_disk_properties(
+            self.driver_name)
+        self.assertNotEmpty(properties)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodes.py b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
new file mode 100644
index 0000000..d992a65
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodes.py
@@ -0,0 +1,403 @@
+#    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 six
+from tempest import config
+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.common import waiters
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+CONF = config.CONF
+
+
+class TestNodes(base.BaseBaremetalTest):
+    """Tests for baremetal nodes."""
+
+    def setUp(self):
+        super(TestNodes, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    def _associate_node_with_instance(self):
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+        waiters.wait_for_bm_node_status(self.client, self.node['uuid'],
+                                        'power_state', 'power off')
+        instance_uuid = data_utils.rand_uuid()
+        self.client.update_node(self.node['uuid'],
+                                instance_uuid=instance_uuid)
+        self.addCleanup(self.client.update_node,
+                        uuid=self.node['uuid'], instance_uuid=None)
+        return instance_uuid
+
+    @decorators.idempotent_id('4e939eb2-8a69-4e84-8652-6fffcbc9db8f')
+    def test_create_node(self):
+        params = {'cpu_arch': 'x86_64',
+                  'cpus': '12',
+                  'local_gb': '10',
+                  'memory_mb': '1024'}
+
+        _, body = self.create_node(self.chassis['uuid'], **params)
+        self._assertExpected(params, body['properties'])
+
+    @decorators.idempotent_id('9ade60a4-505e-4259-9ec4-71352cbbaf47')
+    def test_delete_node(self):
+        _, node = self.create_node(self.chassis['uuid'])
+
+        self.delete_node(node['uuid'])
+
+        self.assertRaises(lib_exc.NotFound, self.client.show_node,
+                          node['uuid'])
+
+    @decorators.idempotent_id('55451300-057c-4ecf-8255-ba42a83d3a03')
+    def test_show_node(self):
+        _, loaded_node = self.client.show_node(self.node['uuid'])
+        self._assertExpected(self.node, loaded_node)
+
+    @decorators.idempotent_id('4ca123c4-160d-4d8d-a3f7-15feda812263')
+    def test_list_nodes(self):
+        _, body = self.client.list_nodes()
+        self.assertIn(self.node['uuid'],
+                      [i['uuid'] for i in body['nodes']])
+
+    @decorators.idempotent_id('85b1f6e0-57fd-424c-aeff-c3422920556f')
+    def test_list_nodes_association(self):
+        _, body = self.client.list_nodes(associated=True)
+        self.assertNotIn(self.node['uuid'],
+                         [n['uuid'] for n in body['nodes']])
+
+        self._associate_node_with_instance()
+
+        _, body = self.client.list_nodes(associated=True)
+        self.assertIn(self.node['uuid'], [n['uuid'] for n in body['nodes']])
+
+        _, body = self.client.list_nodes(associated=False)
+        self.assertNotIn(self.node['uuid'], [n['uuid'] for n in body['nodes']])
+
+    @decorators.idempotent_id('18c4ebd8-f83a-4df7-9653-9fb33a329730')
+    def test_node_port_list(self):
+        _, port = self.create_port(self.node['uuid'],
+                                   data_utils.rand_mac_address())
+        _, body = self.client.list_node_ports(self.node['uuid'])
+        self.assertIn(port['uuid'],
+                      [p['uuid'] for p in body['ports']])
+
+    @decorators.idempotent_id('72591acb-f215-49db-8395-710d14eb86ab')
+    def test_node_port_list_no_ports(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        _, body = self.client.list_node_ports(node['uuid'])
+        self.assertEmpty(body['ports'])
+
+    @decorators.idempotent_id('4fed270a-677a-4d19-be87-fd38ae490320')
+    def test_update_node(self):
+        props = {'cpu_arch': 'x86_64',
+                 'cpus': '12',
+                 'local_gb': '10',
+                 'memory_mb': '128'}
+
+        _, node = self.create_node(self.chassis['uuid'], **props)
+
+        new_p = {'cpu_arch': 'x86',
+                 'cpus': '1',
+                 'local_gb': '10000',
+                 'memory_mb': '12300'}
+
+        _, body = self.client.update_node(node['uuid'], properties=new_p)
+        _, node = self.client.show_node(node['uuid'])
+        self._assertExpected(new_p, node['properties'])
+
+    @decorators.idempotent_id('cbf1f515-5f4b-4e49-945c-86bcaccfeb1d')
+    def test_validate_driver_interface(self):
+        _, body = self.client.validate_driver_interface(self.node['uuid'])
+        core_interfaces = ['power', 'deploy']
+        for interface in core_interfaces:
+            self.assertIn(interface, body)
+
+    @decorators.idempotent_id('5519371c-26a2-46e9-aa1a-f74226e9d71f')
+    def test_set_node_boot_device(self):
+        self.client.set_node_boot_device(self.node['uuid'], 'pxe')
+
+    @decorators.idempotent_id('9ea73775-f578-40b9-bc34-efc639c4f21f')
+    def test_get_node_boot_device(self):
+        body = self.client.get_node_boot_device(self.node['uuid'])
+        self.assertIn('boot_device', body)
+        self.assertIn('persistent', body)
+        self.assertIsInstance(body['boot_device'], six.string_types)
+        self.assertIsInstance(body['persistent'], bool)
+
+    @decorators.idempotent_id('3622bc6f-3589-4bc2-89f3-50419c66b133')
+    def test_get_node_supported_boot_devices(self):
+        body = self.client.get_node_supported_boot_devices(self.node['uuid'])
+        self.assertIn('supported_boot_devices', body)
+        self.assertIsInstance(body['supported_boot_devices'], list)
+
+    @decorators.idempotent_id('f63b6288-1137-4426-8cfe-0d5b7eb87c06')
+    def test_get_console(self):
+        _, body = self.client.get_console(self.node['uuid'])
+        con_info = ['console_enabled', 'console_info']
+        for key in con_info:
+            self.assertIn(key, body)
+
+    @decorators.idempotent_id('80504575-9b21-4670-92d1-143b948f9437')
+    def test_set_console_mode(self):
+        self.client.set_console_mode(self.node['uuid'], True)
+        waiters.wait_for_bm_node_status(self.client, self.node['uuid'],
+                                        'console_enabled', True)
+
+    @decorators.idempotent_id('b02a4f38-5e8b-44b2-aed2-a69a36ecfd69')
+    def test_get_node_by_instance_uuid(self):
+        instance_uuid = self._associate_node_with_instance()
+        _, body = self.client.show_node_by_instance_uuid(instance_uuid)
+        self.assertEqual(1, len(body['nodes']))
+        self.assertIn(self.node['uuid'], [n['uuid'] for n in body['nodes']])
+
+
+class TestNodesResourceClass(base.BaseBaremetalTest):
+
+    min_microversion = '1.21'
+
+    def setUp(self):
+        super(TestNodesResourceClass, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                TestNodesResourceClass.min_microversion)
+        )
+        _, self.chassis = self.create_chassis()
+        self.resource_class = data_utils.rand_name(name='Resource_Class')
+        _, self.node = self.create_node(
+            self.chassis['uuid'], resource_class=self.resource_class)
+
+    @decorators.idempotent_id('2a00340c-8152-4a61-9fc5-0b3cdefec258')
+    def test_create_node_resource_class_long(self):
+        """Create new node with specified longest name of resource class."""
+        res_class_long_name = data_utils.arbitrary_string(80)
+        _, body = self.create_node(
+            self.chassis['uuid'],
+            resource_class=res_class_long_name)
+        self.assertEqual(res_class_long_name, body['resource_class'])
+
+    @decorators.idempotent_id('142db00d-ac0f-415b-8da8-9095fbb561f7')
+    def test_update_node_resource_class(self):
+        """Update existing node with specified resource class."""
+        new_res_class_name = data_utils.rand_name(name='Resource_Class')
+        _, body = self.client.update_node(
+            self.node['uuid'], resource_class=new_res_class_name)
+        _, body = self.client.show_node(self.node['uuid'])
+        self.assertEqual(new_res_class_name, body['resource_class'])
+
+    @decorators.idempotent_id('73e6f7b5-3e51-49ea-af5b-146cd49f40ee')
+    def test_show_node_resource_class(self):
+        """Show resource class field of specified node."""
+        _, body = self.client.show_node(self.node['uuid'])
+        self.assertEqual(self.resource_class, body['resource_class'])
+
+    @decorators.idempotent_id('f2bf4465-280c-4fdc-bbf7-fcf5188befa4')
+    def test_list_nodes_resource_class(self):
+        """List nodes of specified resource class only."""
+        res_class = 'ResClass-{0}'.format(data_utils.rand_uuid())
+        for node in range(3):
+            _, body = self.create_node(
+                self.chassis['uuid'], resource_class=res_class)
+
+        _, body = self.client.list_nodes(resource_class=res_class)
+        self.assertEqual(3, len([i['uuid'] for i in body['nodes']]))
+
+    @decorators.idempotent_id('40733bad-bb79-445e-a094-530a44042995')
+    def test_list_nodes_detail_resource_class(self):
+        """Get detailed nodes list of specified resource class only."""
+        res_class = 'ResClass-{0}'.format(data_utils.rand_uuid())
+        for node in range(3):
+            _, body = self.create_node(
+                self.chassis['uuid'], resource_class=res_class)
+
+        _, body = self.client.list_nodes_detail(resource_class=res_class)
+        self.assertEqual(3, len([i['uuid'] for i in body['nodes']]))
+
+        for node in body['nodes']:
+            self.assertEqual(res_class, node['resource_class'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('e75136d4-0690-48a5-aef3-75040aee73ad')
+    def test_create_node_resource_class_too_long(self):
+        """Try to create a node with too long resource class name."""
+        resource_class = data_utils.arbitrary_string(81)
+        self.assertRaises(lib_exc.BadRequest, self.create_node,
+                          self.chassis['uuid'], resource_class=resource_class)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('f0aeece4-8671-44ea-a482-b4047fc4cf74')
+    def test_update_node_resource_class_too_long(self):
+        """Try to update a node with too long resource class name."""
+        resource_class = data_utils.arbitrary_string(81)
+        self.assertRaises(lib_exc.BadRequest, self.client.update_node,
+                          self.node['uuid'], resource_class=resource_class)
+
+
+class TestNodesResourceClassOldApi(base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodesResourceClassOldApi, self).setUp()
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('2c364408-4746-4b3c-9821-20d47b57bdec')
+    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.chassis['uuid'], resource_class=resource_class)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('666f3c1a-4922-4a3d-b6d9-dea7c74d30bc')
+    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.client.update_node,
+                          self.node['uuid'], resource_class=resource_class)
+
+    @decorators.attr(type='negative')
+    @decorators.idempotent_id('95903480-f16d-4774-8775-6c7f87b27c59')
+    def test_list_nodes_by_resource_class_old_api(self):
+        """Try to list nodes with resource class using older api version."""
+        resource_class = data_utils.arbitrary_string()
+        self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.list_nodes, resource_class=resource_class)
+        self.assertRaises(
+            lib_exc.UnexpectedResponseCode,
+            self.client.list_nodes_detail, resource_class=resource_class)
+
+
+class TestNodesVif(base.BaseBaremetalTest):
+
+    min_microversion = '1.28'
+
+    @classmethod
+    def skip_checks(cls):
+        super(TestNodesVif, cls).skip_checks()
+        if not CONF.service_available.neutron:
+            raise cls.skipException('Neutron is not enabled.')
+
+    def setUp(self):
+        super(TestNodesVif, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        if CONF.network.shared_physical_network:
+            self.net = self.os_admin.networks_client.list_networks(
+                name=CONF.compute.fixed_network_name)['networks'][0]
+        else:
+            self.net = self.os_admin.networks_client.\
+                create_network()['network']
+            self.addCleanup(self.os_admin.networks_client.delete_network,
+                            self.net['id'])
+
+        self.nport_id = self.os_admin.ports_client.create_port(
+            network_id=self.net['id'])['port']['id']
+        self.addCleanup(self.os_admin.ports_client.delete_port,
+                        self.nport_id)
+
+    @decorators.idempotent_id('a3d319d0-cacb-4e55-a3dc-3fa8b74880f1')
+    def test_vif_on_port(self):
+        """Test attachment and detachment of VIFs on the node with port.
+
+        Test steps:
+        1) Create chassis and node in setUp.
+        2) Create port for the node.
+        3) Attach VIF to the node.
+        4) Check VIF info in VIFs list and port internal_info.
+        5) Detach VIF from the node.
+        6) Check that no more VIF info in VIFs list and port internal_info.
+        """
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.28'))
+        _, self.port = self.create_port(self.node['uuid'],
+                                        data_utils.rand_mac_address())
+        self.client.vif_attach(self.node['uuid'], self.nport_id)
+        _, body = self.client.vif_list(self.node['uuid'])
+        self.assertEqual({'vifs': [{'id': self.nport_id}]}, body)
+        _, port = self.client.show_port(self.port['uuid'])
+        self.assertEqual(self.nport_id,
+                         port['internal_info']['tenant_vif_port_id'])
+        self.client.vif_detach(self.node['uuid'], self.nport_id)
+        _, body = self.client.vif_list(self.node['uuid'])
+        self.assertEqual({'vifs': []}, body)
+        _, port = self.client.show_port(self.port['uuid'])
+        self.assertNotIn('tenant_vif_port_id', port['internal_info'])
+
+    @decorators.idempotent_id('95279515-7d0a-4f5f-987f-93e36aae5585')
+    def test_vif_on_portgroup(self):
+        """Test attachment and detachment of VIFs on the node with port group.
+
+        Test steps:
+        1) Create chassis and node in setUp.
+        2) Create port for the node.
+        3) Create port group for the node.
+        4) Plug port into port group.
+        5) Attach VIF to the node.
+        6) Check VIF info in VIFs list and port group internal_info, but
+           not in port internal_info.
+        7) Detach VIF from the node.
+        8) Check that no VIF info in VIFs list and port group internal_info.
+        """
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.28'))
+        _, self.port = self.create_port(self.node['uuid'],
+                                        data_utils.rand_mac_address())
+        _, self.portgroup = self.create_portgroup(
+            self.node['uuid'], address=data_utils.rand_mac_address())
+
+        patch = [{'path': '/portgroup_uuid',
+                  'op': 'add',
+                  'value': self.portgroup['uuid']}]
+        self.client.update_port(self.port['uuid'], patch)
+
+        self.client.vif_attach(self.node['uuid'], self.nport_id)
+        _, body = self.client.vif_list(self.node['uuid'])
+        self.assertEqual({'vifs': [{'id': self.nport_id}]}, body)
+
+        _, port = self.client.show_port(self.port['uuid'])
+        self.assertNotIn('tenant_vif_port_id', port['internal_info'])
+        _, portgroup = self.client.show_portgroup(self.portgroup['uuid'])
+        self.assertEqual(self.nport_id,
+                         portgroup['internal_info']['tenant_vif_port_id'])
+
+        self.client.vif_detach(self.node['uuid'], self.nport_id)
+        _, body = self.client.vif_list(self.node['uuid'])
+        self.assertEqual({'vifs': []}, body)
+        _, portgroup = self.client.show_portgroup(self.portgroup['uuid'])
+        self.assertNotIn('tenant_vif_port_id', portgroup['internal_info'])
+
+    @decorators.idempotent_id('a3d319d0-cacb-4e55-a3dc-3fa8b74880f2')
+    def test_vif_already_set_on_extra(self):
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.28'))
+        _, self.port = self.create_port(self.node['uuid'],
+                                        data_utils.rand_mac_address())
+        patch = [{'path': '/extra/vif_port_id',
+                  'op': 'add',
+                  'value': self.nport_id}]
+        self.client.update_port(self.port['uuid'], patch)
+
+        _, body = self.client.vif_list(self.node['uuid'])
+        self.assertEqual({'vifs': [{'id': self.nport_id}]}, body)
+
+        self.assertRaises(lib_exc.Conflict, self.client.vif_attach,
+                          self.node['uuid'], self.nport_id)
+
+        self.client.vif_detach(self.node['uuid'], self.nport_id)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodestates.py b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py
new file mode 100644
index 0000000..f2a33eb
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py
@@ -0,0 +1,193 @@
+# Copyright 2014 NEC Corporation.  All rights reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from oslo_utils import timeutils
+from tempest.lib import decorators
+from tempest.lib import exceptions
+
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestNodeStatesMixin(object):
+    """Mixin for for baremetal node states tests."""
+
+    @classmethod
+    def resource_setup(cls):
+        super(TestNodeStatesMixin, cls).resource_setup()
+        _, cls.chassis = cls.create_chassis()
+
+    def _validate_power_state(self, node_uuid, power_state):
+        # Validate that power state is set within timeout
+        if power_state == 'rebooting':
+            power_state = 'power on'
+        start = timeutils.utcnow()
+        while timeutils.delta_seconds(
+                start, timeutils.utcnow()) < self.power_timeout:
+            _, node = self.client.show_node(node_uuid)
+            if node['power_state'] == power_state:
+                return
+        message = ('Failed to set power state within '
+                   'the required time: %s sec.' % self.power_timeout)
+        raise exceptions.TimeoutException(message)
+
+    def _validate_provision_state(self, node_uuid, target_state):
+        # Validate that provision state is set within timeout
+        start = timeutils.utcnow()
+        while timeutils.delta_seconds(
+                start, timeutils.utcnow()) < self.unprovision_timeout:
+            _, node = self.client.show_node(node_uuid)
+            if node['provision_state'] == target_state:
+                return
+        message = ('Failed to set provision state %(state)s within '
+                   'the required time: %(timeout)s sec.',
+                   {'state': target_state,
+                    'timeout': self.unprovision_timeout})
+        raise exceptions.TimeoutException(message)
+
+    @decorators.idempotent_id('cd8afa5e-3f57-4e43-8185-beb83d3c9015')
+    def test_list_nodestates(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        _, nodestates = self.client.list_nodestates(node['uuid'])
+        for key in nodestates:
+            self.assertEqual(nodestates[key], node[key])
+
+    @decorators.idempotent_id('fc5b9320-0c98-4e5a-8848-877fe5a0322c')
+    def test_set_node_power_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        states = ["power on", "rebooting", "power off"]
+        for state in states:
+            # Set power state
+            self.client.set_node_power_state(node['uuid'], state)
+            # Check power state after state is set
+            self._validate_power_state(node['uuid'], state)
+
+
+class TestNodeStatesV1_1(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    @decorators.idempotent_id('ccb8fca9-2ba0-480c-a037-34c3bd09dc74')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in NONE state by default until v1.1
+        self.assertIsNone(node['provision_state'])
+        provision_states_list = ['active', 'deleted']
+        target_states_list = ['active', None]
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_2(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_2, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.2'))
+
+    @decorators.idempotent_id('9c414984-f3b6-4b3d-81da-93b60d4662fb')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        provision_states_list = ['active', 'deleted']
+        target_states_list = ['active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_4(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_4, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.4'))
+
+    @decorators.idempotent_id('3d606003-05ce-4b5a-964d-bdee382fafe9')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        # MANAGEABLE state and PROVIDE transition have been added in v1.4
+        provision_states_list = [
+            'manage', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_6(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_6, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.6'))
+
+    @decorators.idempotent_id('6c9ce4a3-713b-4c76-91af-18c48d01f1bb')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        # INSPECT* states have been added in v1.6
+        provision_states_list = [
+            'manage', 'inspect', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_11(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_11, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.11')
+        )
+
+    @decorators.idempotent_id('31f53828-b83d-40c7-98e5-843e28a1b6b9')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in ENROLL state by default from v1.11
+        self.assertEqual('enroll', node['provision_state'])
+        provision_states_list = [
+            'manage', 'inspect', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_12(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_12, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.12')
+        )
+
+    @decorators.idempotent_id('4427b1ca-8e79-4139-83d6-77dfac03e61e')
+    def test_set_node_raid_config(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        target_raid_config = {'logical_disks': [{'size_gb': 100,
+                                                 'raid_level': '1'}]}
+        self.client.set_node_raid_config(node['uuid'], target_raid_config)
+        _, ret = self.client.show_node(node['uuid'])
+        self.assertEqual(target_raid_config, ret['target_raid_config'])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_portgroups.py b/ironic_tempest_plugin/tests/api/admin/test_portgroups.py
new file mode 100644
index 0000000..4a5b84b
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_portgroups.py
@@ -0,0 +1,74 @@
+#    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.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestPortGroups(base.BaseBaremetalTest):
+    """Basic positive test cases for port groups."""
+
+    min_microversion = '1.23'
+
+    def setUp(self):
+        super(TestPortGroups, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.min_microversion))
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        _, self.portgroup = self.create_portgroup(
+            self.node['uuid'], address=data_utils.rand_mac_address(),
+            name=data_utils.rand_name('portgroup'))
+
+    @decorators.idempotent_id('110cd302-256b-4ddc-be10-fc6c9ad8e649')
+    def test_create_portgroup_with_address(self):
+        """Create a port group with specific MAC address."""
+        _, body = self.client.show_portgroup(self.portgroup['uuid'])
+        self.assertEqual(self.portgroup['address'], body['address'])
+
+    @decorators.idempotent_id('4336fa0f-da86-4cec-b788-89f59a7635a5')
+    def test_create_portgroup_no_address(self):
+        """Create a port group without setting MAC address."""
+        _, portgroup = self.create_portgroup(self.node['uuid'])
+        _, body = self.client.show_portgroup(portgroup['uuid'])
+
+        self._assertExpected(portgroup, body)
+        self.assertIsNone(body['address'])
+
+    @decorators.idempotent_id('8378c69f-f806-454b-8ddd-6b7fd93ab12b')
+    def test_delete_portgroup(self):
+        """Delete a port group."""
+        self.delete_portgroup(self.portgroup['uuid'])
+        self.assertRaises(lib_exc.NotFound, self.client.show_portgroup,
+                          self.portgroup['uuid'])
+
+    @decorators.idempotent_id('f6be5e70-3e3b-435c-b2fc-bbb2cc9b3185')
+    def test_show_portgroup(self):
+        """Show a specified port group."""
+        _, portgroup = self.client.show_portgroup(self.portgroup['uuid'])
+        self._assertExpected(self.portgroup, portgroup)
+
+    @decorators.idempotent_id('cf2dfd95-5ea1-4109-8ad3-297cd76aa5d3')
+    def test_list_portgroups(self):
+        """List port groups."""
+        _, body = self.client.list_portgroups()
+        self.assertIn(self.portgroup['uuid'],
+                      [i['uuid'] for i in body['portgroups']])
+        self.assertIn(self.portgroup['address'],
+                      [i['address'] for i in body['portgroups']])
+        self.assertIn(self.portgroup['name'],
+                      [i['name'] for i in body['portgroups']])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports.py b/ironic_tempest_plugin/tests/api/admin/test_ports.py
new file mode 100644
index 0000000..a4aea4f
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_ports.py
@@ -0,0 +1,376 @@
+#    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.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestPorts(base.BaseBaremetalTest):
+    """Tests for ports."""
+
+    def setUp(self):
+        super(TestPorts, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        _, self.port = self.create_port(self.node['uuid'],
+                                        data_utils.rand_mac_address())
+
+    @decorators.idempotent_id('83975898-2e50-42ed-b5f0-e510e36a0b56')
+    def test_create_port(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+
+        _, body = self.client.show_port(port['uuid'])
+
+        self._assertExpected(port, body)
+
+    @decorators.idempotent_id('d1f6b249-4cf6-4fe6-9ed6-a6e84b1bf67b')
+    def test_create_port_specifying_uuid(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        uuid = data_utils.rand_uuid()
+
+        _, port = self.create_port(node_id=node_id,
+                                   address=address, uuid=uuid)
+
+        _, body = self.client.show_port(uuid)
+        self._assertExpected(port, body)
+
+    @decorators.idempotent_id('4a02c4b0-6573-42a4-a513-2e36ad485b62')
+    def test_create_port_with_extra(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'str': 'value', 'int': 123, 'float': 0.123,
+                 'bool': True, 'list': [1, 2, 3], 'dict': {'foo': 'bar'}}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+
+        _, body = self.client.show_port(port['uuid'])
+        self._assertExpected(port, body)
+
+    @decorators.idempotent_id('1bf257a9-aea3-494e-89c0-63f657ab4fdd')
+    def test_delete_port(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        _, port = self.create_port(node_id=node_id, address=address)
+
+        self.delete_port(port['uuid'])
+
+        self.assertRaises(lib_exc.NotFound, self.client.show_port,
+                          port['uuid'])
+
+    @decorators.idempotent_id('9fa77ab5-ce59-4f05-baac-148904ba1597')
+    def test_show_port(self):
+        _, port = self.client.show_port(self.port['uuid'])
+        self._assertExpected(self.port, port)
+
+    @decorators.idempotent_id('7c1114ff-fc3f-47bb-bc2f-68f61620ba8b')
+    def test_show_port_by_address(self):
+        _, port = self.client.show_port_by_address(self.port['address'])
+        self._assertExpected(self.port, port['ports'][0])
+
+    @decorators.idempotent_id('bd773405-aea5-465d-b576-0ab1780069e5')
+    def test_show_port_with_links(self):
+        _, port = self.client.show_port(self.port['uuid'])
+        self.assertIn('links', port.keys())
+        self.assertEqual(2, len(port['links']))
+        self.assertIn(port['uuid'], port['links'][0]['href'])
+
+    @decorators.idempotent_id('b5e91854-5cd7-4a8e-bb35-3e0a1314606d')
+    def test_list_ports(self):
+        _, body = self.client.list_ports()
+        self.assertIn(self.port['uuid'],
+                      [i['uuid'] for i in body['ports']])
+        # Verify self links.
+        for port in body['ports']:
+            self.validate_self_link('ports', port['uuid'],
+                                    port['links'][0]['href'])
+
+    @decorators.idempotent_id('324a910e-2f80-4258-9087-062b5ae06240')
+    def test_list_with_limit(self):
+        _, body = self.client.list_ports(limit=3)
+
+        next_marker = body['ports'][-1]['uuid']
+        self.assertIn(next_marker, body['next'])
+
+    @decorators.idempotent_id('8a94b50f-9895-4a63-a574-7ecff86e5875')
+    def test_list_ports_details(self):
+        node_id = self.node['uuid']
+
+        uuids = [
+            self.create_port(node_id=node_id,
+                             address=data_utils.rand_mac_address())
+            [1]['uuid'] for i in range(0, 5)]
+
+        _, body = self.client.list_ports_detail()
+
+        ports_dict = dict((port['uuid'], port) for port in body['ports']
+                          if port['uuid'] in uuids)
+
+        for uuid in uuids:
+            self.assertIn(uuid, ports_dict)
+            port = ports_dict[uuid]
+            self.assertIn('extra', port)
+            self.assertIn('node_uuid', port)
+            # never expose the node_id
+            self.assertNotIn('node_id', port)
+            # Verify self link.
+            self.validate_self_link('ports', port['uuid'],
+                                    port['links'][0]['href'])
+
+    @decorators.idempotent_id('8a03f688-7d75-4ecd-8cbc-e06b8f346738')
+    def test_list_ports_details_with_address(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        self.create_port(node_id=node_id, address=address)
+        for i in range(0, 5):
+            self.create_port(node_id=node_id,
+                             address=data_utils.rand_mac_address())
+
+        _, body = self.client.list_ports_detail(address=address)
+        self.assertEqual(1, len(body['ports']))
+        self.assertEqual(address, body['ports'][0]['address'])
+
+    @decorators.idempotent_id('9c26298b-1bcb-47b7-9b9e-8bdd6e3c4aba')
+    def test_update_port_replace(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+
+        new_address = data_utils.rand_mac_address()
+        new_extra = {'key1': 'new-value1', 'key2': 'new-value2',
+                     'key3': 'new-value3'}
+
+        patch = [{'path': '/address',
+                  'op': 'replace',
+                  'value': new_address},
+                 {'path': '/extra/key1',
+                  'op': 'replace',
+                  'value': new_extra['key1']},
+                 {'path': '/extra/key2',
+                  'op': 'replace',
+                  'value': new_extra['key2']},
+                 {'path': '/extra/key3',
+                  'op': 'replace',
+                  'value': new_extra['key3']}]
+
+        self.client.update_port(port['uuid'], patch)
+
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual(new_address, body['address'])
+        self.assertEqual(new_extra, body['extra'])
+
+    @decorators.idempotent_id('d7e7fece-6ed9-460a-9ebe-9267217e8580')
+    def test_update_port_remove(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+
+        # Removing one item from the collection
+        self.client.update_port(port['uuid'],
+                                [{'path': '/extra/key2',
+                                 'op': 'remove'}])
+        extra.pop('key2')
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual(extra, body['extra'])
+
+        # Removing the collection
+        self.client.update_port(port['uuid'], [{'path': '/extra',
+                                               'op': 'remove'}])
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual({}, body['extra'])
+
+        # Assert nothing else was changed
+        self.assertEqual(node_id, body['node_uuid'])
+        self.assertEqual(address, body['address'])
+
+    @decorators.idempotent_id('241288b3-e98a-400f-a4d7-d1f716146361')
+    def test_update_port_add(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+
+        extra = {'key1': 'value1', 'key2': 'value2'}
+
+        patch = [{'path': '/extra/key1',
+                  'op': 'add',
+                  'value': extra['key1']},
+                 {'path': '/extra/key2',
+                  'op': 'add',
+                  'value': extra['key2']}]
+
+        self.client.update_port(port['uuid'], patch)
+
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual(extra, body['extra'])
+
+    @decorators.idempotent_id('5309e897-0799-4649-a982-0179b04c3876')
+    def test_update_port_mixed_ops(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key1': 'value1', 'key2': 'value2'}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+
+        new_address = data_utils.rand_mac_address()
+        new_extra = {'key1': 0.123, 'key3': {'cat': 'meow'}}
+
+        patch = [{'path': '/address',
+                  'op': 'replace',
+                  'value': new_address},
+                 {'path': '/extra/key1',
+                  'op': 'replace',
+                  'value': new_extra['key1']},
+                 {'path': '/extra/key2',
+                  'op': 'remove'},
+                 {'path': '/extra/key3',
+                  'op': 'add',
+                  'value': new_extra['key3']}]
+
+        self.client.update_port(port['uuid'], patch)
+
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual(new_address, body['address'])
+        self.assertEqual(new_extra, body['extra'])
+
+
+class TestPortsWithPhysicalNetwork(base.BaseBaremetalTest):
+    """Tests for ports with physical network information."""
+
+    min_microversion = '1.34'
+
+    def setUp(self):
+        super(TestPortsWithPhysicalNetwork, self).setUp()
+
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                TestPortsWithPhysicalNetwork.min_microversion)
+        )
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.idempotent_id('f1a5d279-c456-4311-ad31-fea09f61c22b')
+    def test_create_port_with_physical_network(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   physical_network='physnet1')
+
+        _, body = self.client.show_port(port['uuid'])
+
+        self._assertExpected(port, body)
+        self.assertEqual('physnet1', port['physical_network'])
+
+    @decorators.idempotent_id('9c26298b-1bcb-47b7-9b9e-8bdd6e3c4aba')
+    def test_update_port_replace_physical_network(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   physical_network='physnet1')
+
+        new_physnet = 'physnet2'
+
+        patch = [{'path': '/physical_network',
+                  'op': 'replace',
+                  'value': new_physnet}]
+
+        self.client.update_port(port['uuid'], patch)
+
+        _, body = self.client.show_port(port['uuid'])
+        self.assertEqual(new_physnet, body['physical_network'])
+
+    @decorators.idempotent_id('6503309c-b2c7-4f59-b15a-0d92b5de9210')
+    def test_update_port_remove_physical_network(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   physical_network='physnet1')
+
+        patch = [{'path': '/physical_network',
+                  'op': 'remove'}]
+
+        self.client.update_port(port['uuid'], patch)
+
+        _, body = self.client.show_port(port['uuid'])
+        self.assertIsNone(body['physical_network'])
+
+    @decorators.idempotent_id('4155c24d-8474-4b53-a320-aee475f85a68')
+    def test_create_ports_in_portgroup_with_physical_network(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, portgroup = self.create_portgroup(node_id, address=address)
+
+        _, port1 = self.create_port(node_id=node_id, address=address,
+                                    portgroup_uuid=portgroup['uuid'],
+                                    physical_network='physnet1')
+
+        address = data_utils.rand_mac_address()
+        _, port2 = self.create_port(node_id=node_id, address=address,
+                                    portgroup_uuid=portgroup['uuid'],
+                                    physical_network='physnet1')
+
+        _, body = self.client.show_port(port1['uuid'])
+        self.assertEqual('physnet1', body['physical_network'])
+        self.assertEqual(portgroup['uuid'], body['portgroup_uuid'])
+
+        _, body = self.client.show_port(port2['uuid'])
+        self.assertEqual('physnet1', body['physical_network'])
+        self.assertEqual(portgroup['uuid'], body['portgroup_uuid'])
+
+    @decorators.idempotent_id('cf05a3ef-3bc4-4db7-bb4c-4eb871eb9f81')
+    def test_update_ports_in_portgroup_with_physical_network(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, portgroup = self.create_portgroup(node_id, address=address)
+
+        _, port1 = self.create_port(node_id=node_id, address=address,
+                                    portgroup_uuid=portgroup['uuid'],
+                                    physical_network='physnet1')
+
+        address = data_utils.rand_mac_address()
+        _, port2 = self.create_port(node_id=node_id, address=address,
+                                    physical_network='physnet1')
+
+        patch = [{'path': '/portgroup_uuid',
+                  'op': 'replace',
+                  'value': portgroup['uuid']}]
+
+        self.client.update_port(port2['uuid'], patch)
+
+        _, body = self.client.show_port(port1['uuid'])
+        self.assertEqual('physnet1', body['physical_network'])
+        self.assertEqual(portgroup['uuid'], body['portgroup_uuid'])
+
+        _, body = self.client.show_port(port2['uuid'])
+        self.assertEqual('physnet1', body['physical_network'])
+        self.assertEqual(portgroup['uuid'], body['portgroup_uuid'])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py
new file mode 100644
index 0000000..c25020b
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_ports_negative.py
@@ -0,0 +1,463 @@
+#    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.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestPortsNegative(base.BaseBaremetalTest):
+    """Negative tests for ports."""
+
+    def setUp(self):
+        super(TestPortsNegative, self).setUp()
+
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0a6ee1f7-d0d9-4069-8778-37f3aa07303a')
+    def test_create_port_malformed_mac(self):
+        node_id = self.node['uuid']
+        address = 'malformed:mac'
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.create_port, node_id=node_id, address=address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('30277ee8-0c60-4f1d-b125-0e51c2f43369')
+    def test_create_port_nonexsistent_node_id(self):
+        node_id = str(data_utils.rand_uuid())
+        address = data_utils.rand_mac_address()
+        self.assertRaises(lib_exc.BadRequest, self.create_port,
+                          node_id=node_id, address=address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('029190f6-43e1-40a3-b64a-65173ba653a3')
+    def test_show_port_malformed_uuid(self):
+        self.assertRaises(lib_exc.BadRequest, self.client.show_port,
+                          'malformed:uuid')
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0d00e13d-e2e0-45b1-bcbc-55a6d90ca793')
+    def test_show_port_nonexistent_uuid(self):
+        self.assertRaises(lib_exc.NotFound, self.client.show_port,
+                          data_utils.rand_uuid())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('4ad85266-31e9-4942-99ac-751897dc9e23')
+    def test_show_port_by_mac_not_allowed(self):
+        self.assertRaises(lib_exc.BadRequest, self.client.show_port,
+                          data_utils.rand_mac_address())
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('89a34380-3c61-4c32-955c-2cd9ce94da21')
+    def test_create_port_duplicated_port_uuid(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        uuid = data_utils.rand_uuid()
+
+        self.create_port(node_id=node_id, address=address, uuid=uuid)
+        self.assertRaises(lib_exc.Conflict, self.create_port, node_id=node_id,
+                          address=address, uuid=uuid)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('65e84917-733c-40ae-ae4b-96a4adff931c')
+    def test_create_port_no_mandatory_field_node_id(self):
+        address = data_utils.rand_mac_address()
+
+        self.assertRaises(lib_exc.BadRequest, self.create_port, node_id=None,
+                          address=address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('bcea3476-7033-4183-acfe-e56a30809b46')
+    def test_create_port_no_mandatory_field_mac(self):
+        node_id = self.node['uuid']
+
+        self.assertRaises(lib_exc.BadRequest, self.create_port,
+                          node_id=node_id, address=None)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2b51cd18-fb95-458b-9780-e6257787b649')
+    def test_create_port_malformed_port_uuid(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        uuid = 'malformed:uuid'
+
+        self.assertRaises(lib_exc.BadRequest, self.create_port,
+                          node_id=node_id, address=address, uuid=uuid)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('583a6856-6a30-4ac4-889f-14e2adff8105')
+    def test_create_port_malformed_node_id(self):
+        address = data_utils.rand_mac_address()
+        self.assertRaises(lib_exc.BadRequest, self.create_port,
+                          node_id='malformed:nodeid', address=address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e27f8b2e-42c6-4a43-a3cd-accff716bc5c')
+    def test_create_port_duplicated_mac(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        self.create_port(node_id=node_id, address=address)
+        self.assertRaises(lib_exc.Conflict,
+                          self.create_port, node_id=node_id,
+                          address=address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('8907082d-ac5e-4be3-b05f-d072ede82020')
+    def test_update_port_by_mac_not_allowed(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key': 'value'}
+
+        self.create_port(node_id=node_id, address=address, extra=extra)
+
+        patch = [{'path': '/extra/key',
+                  'op': 'replace',
+                  'value': 'new-value'}]
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_port, address,
+                          patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('df1ac70c-db9f-41d9-90f1-78cd6b905718')
+    def test_update_port_nonexistent(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key': 'value'}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+        port_id = port['uuid']
+
+        _, body = self.client.delete_port(port_id)
+
+        patch = [{'path': '/extra/key',
+                  'op': 'replace',
+                  'value': 'new-value'}]
+        self.assertRaises(lib_exc.NotFound,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('c701e315-aa52-41ea-817c-65c5ca8ca2a8')
+    def test_update_port_malformed_port_uuid(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        self.create_port(node_id=node_id, address=address)
+
+        new_address = data_utils.rand_mac_address()
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port,
+                          uuid='malformed:uuid',
+                          patch=[{'path': '/address', 'op': 'replace',
+                                  'value': new_address}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('f8f15803-34d6-45dc-b06f-e5e04bf1b38b')
+    def test_update_port_add_nonexistent_property(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id,
+                          [{'path': '/nonexistent', ' op': 'add',
+                            'value': 'value'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('898ec904-38b1-4fcb-9584-1187d4263a2a')
+    def test_update_port_replace_node_id_with_malformed(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        patch = [{'path': '/node_uuid',
+                  'op': 'replace',
+                  'value': 'malformed:node_uuid'}]
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('2949f30f-5f59-43fa-a6d9-4eac578afab4')
+    def test_update_port_replace_mac_with_duplicated(self):
+        node_id = self.node['uuid']
+        address1 = data_utils.rand_mac_address()
+        address2 = data_utils.rand_mac_address()
+
+        _, port1 = self.create_port(node_id=node_id, address=address1)
+
+        _, port2 = self.create_port(node_id=node_id, address=address2)
+        port_id = port2['uuid']
+
+        patch = [{'path': '/address',
+                  'op': 'replace',
+                  'value': address1}]
+        self.assertRaises(lib_exc.Conflict,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('97f6e048-6e4f-4eba-a09d-fbbc78b77a77')
+    def test_update_port_replace_node_id_with_nonexistent(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        patch = [{'path': '/node_uuid',
+                  'op': 'replace',
+                  'value': data_utils.rand_uuid()}]
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('375022c5-9e9e-4b11-9ca4-656729c0c9b2')
+    def test_update_port_replace_mac_with_malformed(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        patch = [{'path': '/address',
+                  'op': 'replace',
+                  'value': 'malformed:mac'}]
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('5722b853-03fc-4854-8308-2036a1b67d85')
+    def test_update_port_replace_nonexistent_property(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        patch = [{'path': '/nonexistent', ' op': 'replace', 'value': 'value'}]
+
+        self.assertRaises(lib_exc.BadRequest,
+                          self.client.update_port, port_id, patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ae2696ca-930a-4a7f-918f-30ae97c60f56')
+    def test_update_port_remove_mandatory_field_mac(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id,
+                          [{'path': '/address', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('5392c1f0-2071-4697-9064-ec2d63019018')
+    def test_update_port_remove_mandatory_field_port_uuid(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id,
+                          [{'path': '/uuid', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('06b50d82-802a-47ef-b079-0a3311cf85a2')
+    def test_update_port_remove_nonexistent_property(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, port = self.create_port(node_id=node_id, address=address)
+        port_id = port['uuid']
+
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id,
+                          [{'path': '/nonexistent', 'op': 'remove'}])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('03d42391-2145-4a6c-95bf-63fe55eb64fd')
+    def test_delete_port_by_mac_not_allowed(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        self.create_port(node_id=node_id, address=address)
+        self.assertRaises(lib_exc.BadRequest, self.client.delete_port, address)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0629e002-818e-4763-b25b-ae5e07b1cb23')
+    def test_update_port_mixed_ops_integrity(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+        extra = {'key1': 'value1', 'key2': 'value2'}
+
+        _, port = self.create_port(node_id=node_id, address=address,
+                                   extra=extra)
+        port_id = port['uuid']
+
+        new_address = data_utils.rand_mac_address()
+        new_extra = {'key1': 'new-value1', 'key3': 'new-value3'}
+
+        patch = [{'path': '/address',
+                  'op': 'replace',
+                  'value': new_address},
+                 {'path': '/extra/key1',
+                  'op': 'replace',
+                  'value': new_extra['key1']},
+                 {'path': '/extra/key2',
+                  'op': 'remove'},
+                 {'path': '/extra/key3',
+                  'op': 'add',
+                  'value': new_extra['key3']},
+                 {'path': '/nonexistent',
+                  'op': 'replace',
+                  'value': 'value'}]
+
+        self.assertRaises(lib_exc.BadRequest, self.client.update_port, port_id,
+                          patch)
+
+        # patch should not be applied
+        _, body = self.client.show_port(port_id)
+        self.assertEqual(address, body['address'])
+        self.assertEqual(extra, body['extra'])
+
+
+class TestPortsWithPhysicalNetworkOldAPI(base.BaseBaremetalTest):
+    """Negative tests for ports with physical network information."""
+
+    def setUp(self):
+        super(TestPortsWithPhysicalNetworkOldAPI, self).setUp()
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('307e57e9-082f-4830-9480-91affcbfda08')
+    def test_create_port_with_physical_network_old_api(self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        self.assertRaises((lib_exc.BadRequest, lib_exc.UnexpectedResponseCode),
+                          self.create_port,
+                          node_id=node_id, address=address,
+                          physical_network='physnet1')
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('0b278c0a-d334-424e-a5c5-b6d001c2a715')
+    def test_update_port_replace_physical_network_old_api(self):
+        _, port = self.create_port(self.node['uuid'],
+                                   data_utils.rand_mac_address())
+
+        new_physnet = 'physnet1'
+
+        patch = [{'path': '/physical_network',
+                  'op': 'replace',
+                  'value': new_physnet}]
+
+        self.assertRaises((lib_exc.BadRequest, lib_exc.UnexpectedResponseCode),
+                          self.client.update_port,
+                          port['uuid'], patch)
+
+
+class TestPortsNegativeWithPhysicalNetwork(base.BaseBaremetalTest):
+    """Negative tests for ports with physical network information."""
+
+    min_microversion = '1.34'
+
+    def setUp(self):
+        super(TestPortsNegativeWithPhysicalNetwork, self).setUp()
+
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                TestPortsNegativeWithPhysicalNetwork.min_microversion)
+        )
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('e20156fb-956b-4d5b-89a4-f379044a1d3c')
+    def test_create_ports_in_portgroup_with_inconsistent_physical_network(
+            self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, portgroup = self.create_portgroup(node_id, address=address)
+
+        _, _ = self.create_port(node_id=node_id, address=address,
+                                portgroup_uuid=portgroup['uuid'],
+                                physical_network='physnet1')
+
+        address = data_utils.rand_mac_address()
+        self.assertRaises(lib_exc.Conflict,
+                          self.create_port,
+                          node_id=node_id, address=address,
+                          portgroup_uuid=portgroup['uuid'],
+                          physical_network='physnet2')
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('050e792c-22c9-4e4a-ae89-dfbfc52ad00d')
+    def test_update_ports_in_portgroup_with_inconsistent_physical_network(
+            self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, portgroup = self.create_portgroup(node_id, address=address)
+
+        _, _ = self.create_port(node_id=node_id, address=address,
+                                portgroup_uuid=portgroup['uuid'],
+                                physical_network='physnet1')
+
+        address = data_utils.rand_mac_address()
+        _, port2 = self.create_port(node_id=node_id, address=address,
+                                    physical_network='physnet2')
+
+        patch = [{'path': '/portgroup_uuid',
+                  'op': 'replace',
+                  'value': portgroup['uuid']}]
+
+        self.assertRaises(lib_exc.Conflict,
+                          self.client.update_port,
+                          port2['uuid'], patch)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('3cd1c8ec-57d1-40cb-922b-dd02431beea3')
+    def test_update_ports_in_portgroup_with_inconsistent_physical_network_2(
+            self):
+        node_id = self.node['uuid']
+        address = data_utils.rand_mac_address()
+
+        _, portgroup = self.create_portgroup(node_id, address=address)
+
+        _, _ = self.create_port(node_id=node_id, address=address,
+                                portgroup_uuid=portgroup['uuid'],
+                                physical_network='physnet1')
+
+        address = data_utils.rand_mac_address()
+        _, port2 = self.create_port(node_id=node_id, address=address,
+                                    portgroup_uuid=portgroup['uuid'],
+                                    physical_network='physnet1')
+
+        patch = [{'path': '/physical_network',
+                  'op': 'replace',
+                  'value': 'physnet2'}]
+
+        self.assertRaises(lib_exc.Conflict,
+                          self.client.update_port,
+                          port2['uuid'], patch)
diff --git a/ironic_tempest_plugin/tests/api/admin/test_volume_connector.py b/ironic_tempest_plugin/tests/api/admin/test_volume_connector.py
new file mode 100644
index 0000000..0b936a2
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_volume_connector.py
@@ -0,0 +1,227 @@
+# Copyright 2017 FUJITSU LIMITED
+#
+# 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.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestVolumeConnector(base.BaseBaremetalTest):
+    """Basic test cases for volume connector."""
+
+    min_microversion = '1.32'
+    extra = {'key1': 'value1', 'key2': 'value2'}
+
+    def setUp(self):
+        super(TestVolumeConnector, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.min_microversion))
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        _, self.volume_connector = self.create_volume_connector(
+            self.node['uuid'], type='iqn',
+            connector_id=data_utils.rand_name('connector_id'),
+            extra=self.extra)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('3c3cbf45-488a-4386-a811-bf0aa2589c58')
+    def test_create_volume_connector_error(self):
+        """Create a volume connector.
+
+        Fail when creating a volume connector with same connector_id
+        & type as an existing volume connector.
+        """
+        regex_str = (r'.*A volume connector .*already exists')
+
+        self.assertRaisesRegex(
+            lib_exc.Conflict, regex_str,
+            self.create_volume_connector,
+            self.node['uuid'],
+            type=self.volume_connector['type'],
+            connector_id=self.volume_connector['connector_id'])
+
+    @decorators.idempotent_id('5795f816-0789-42e6-bb9c-91b4876ad13f')
+    def test_delete_volume_connector(self):
+        """Delete a volume connector."""
+        # Powering off the Node before deleting a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.delete_volume_connector(self.volume_connector['uuid'])
+        self.assertRaises(lib_exc.NotFound, self.client.show_volume_connector,
+                          self.volume_connector['uuid'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('ccbda5e6-52b7-400c-94d7-25eec1d590f0')
+    def test_delete_volume_connector_error(self):
+        """Delete a volume connector
+
+        Fail when deleting a volume connector on node
+        with powered on state.
+        """
+
+        # Powering on the Node before deleting a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power on')
+
+        regex_str = (r'.*The requested action \\\\"volume connector '
+                     r'deletion\\\\" can not be performed on node*')
+
+        self.assertRaisesRegex(lib_exc.BadRequest,
+                               regex_str,
+                               self.delete_volume_connector,
+                               self.volume_connector['uuid'])
+
+    @decorators.idempotent_id('6e4f50b7-0f4f-41c2-971e-d751abcac4e0')
+    def test_show_volume_connector(self):
+        """Show a specified volume connector."""
+        _, volume_connector = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        self._assertExpected(self.volume_connector, volume_connector)
+
+    @decorators.idempotent_id('a4725778-e164-4ee5-96a0-66119a35f783')
+    def test_list_volume_connectors(self):
+        """List volume connectors."""
+        _, body = self.client.list_volume_connectors()
+        self.assertIn(self.volume_connector['uuid'],
+                      [i['uuid'] for i in body['connectors']])
+        self.assertIn(self.volume_connector['type'],
+                      [i['type'] for i in body['connectors']])
+        self.assertIn(self.volume_connector['connector_id'],
+                      [i['connector_id'] for i in body['connectors']])
+
+    @decorators.idempotent_id('1d0459ad-01c0-46db-b930-7301bc2a3c98')
+    def test_list_with_limit(self):
+        """List volume connectors with limit."""
+        _, body = self.client.list_volume_connectors(limit=3)
+
+        next_marker = body['connectors'][-1]['uuid']
+        self.assertIn(next_marker, body['next'])
+
+    @decorators.idempotent_id('3c6f8354-e9bd-4f21-aae2-6deb96b04be7')
+    def test_update_volume_connector_replace(self):
+        """Update a volume connector with new connector id."""
+        new_connector_id = data_utils.rand_name('connector_id')
+
+        patch = [{'path': '/connector_id',
+                  'op': 'replace',
+                  'value': new_connector_id}]
+
+        # Powering off the Node before updating a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.client.update_volume_connector(
+            self.volume_connector['uuid'], patch)
+
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        self.assertEqual(new_connector_id, body['connector_id'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('5af8dc7a-9965-4787-8184-e60aeaf30957')
+    def test_update_volume_connector_replace_error(self):
+        """Updating a volume connector.
+
+        Fail when updating a volume connector on node
+        with power on state.
+        """
+
+        new_connector_id = data_utils.rand_name('connector_id')
+
+        patch = [{'path': '/connector_id',
+                  'op': 'replace',
+                  'value': new_connector_id}]
+
+        # Powering on the Node before updating a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power on')
+
+        regex_str = (r'.*The requested action \\\\"volume connector '
+                     r'update\\\\" can not be performed on node*')
+        self.assertRaisesRegex(lib_exc.BadRequest,
+                               regex_str,
+                               self.client.update_volume_connector,
+                               self.volume_connector['uuid'],
+                               patch)
+
+    @decorators.idempotent_id('b95c75eb-4048-482e-99ff-fe1d32538383')
+    def test_update_volume_connector_remove_item(self):
+        """Update a volume connector by removing one item from collection."""
+        new_extra = {'key1': 'value1'}
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        connector_id = body['connector_id']
+        connector_type = body['type']
+
+        # Powering off the Node before updating a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        # Removing one item from the collection
+        self.client.update_volume_connector(self.volume_connector['uuid'],
+                                            [{'path': '/extra/key2',
+                                              'op': 'remove'}])
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        self.assertEqual(new_extra, body['extra'])
+
+        # Assert nothing else was changed
+        self.assertEqual(connector_id, body['connector_id'])
+        self.assertEqual(connector_type, body['type'])
+
+    @decorators.idempotent_id('8de03acd-532a-476f-8bc9-0e8b23bfe609')
+    def test_update_volume_connector_remove_collection(self):
+        """Update a volume connector by removing collection."""
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        connector_id = body['connector_id']
+        connector_type = body['type']
+
+        # Powering off the Node before updating a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        # Removing the collection
+        self.client.update_volume_connector(self.volume_connector['uuid'],
+                                            [{'path': '/extra',
+                                              'op': 'remove'}])
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        self.assertEqual({}, body['extra'])
+
+        # Assert nothing else was changed
+        self.assertEqual(connector_id, body['connector_id'])
+        self.assertEqual(connector_type, body['type'])
+
+    @decorators.idempotent_id('bfb0ca6b-086d-4663-9b25-e0eaf42da55b')
+    def test_update_volume_connector_add(self):
+        """Update a volume connector by adding one item to collection."""
+        new_extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}
+
+        patch = [{'path': '/extra/key3',
+                  'op': 'add',
+                  'value': new_extra['key3']},
+                 {'path': '/extra/key3',
+                  'op': 'add',
+                  'value': new_extra['key3']}]
+
+        # Powering off the Node before updating a volume connector.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.client.update_volume_connector(
+            self.volume_connector['uuid'], patch)
+
+        _, body = self.client.show_volume_connector(
+            self.volume_connector['uuid'])
+        self.assertEqual(new_extra, body['extra'])
diff --git a/ironic_tempest_plugin/tests/api/admin/test_volume_target.py b/ironic_tempest_plugin/tests/api/admin/test_volume_target.py
new file mode 100644
index 0000000..731467c
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/test_volume_target.py
@@ -0,0 +1,210 @@
+# Copyright 2017 FUJITSU LIMITED
+#
+# 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.admin import api_microversion_fixture
+from ironic_tempest_plugin.tests.api.admin import base
+
+
+class TestVolumeTarget(base.BaseBaremetalTest):
+    """Basic test cases for volume target."""
+
+    min_microversion = '1.32'
+    extra = {'key1': 'value1', 'key2': 'value2'}
+
+    def setUp(self):
+        super(TestVolumeTarget, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture(
+                self.min_microversion))
+        _, self.chassis = self.create_chassis()
+        _, self.node = self.create_node(self.chassis['uuid'])
+        _, self.volume_target = self.create_volume_target(
+            self.node['uuid'], volume_type=data_utils.rand_name('volume_type'),
+            volume_id=data_utils.rand_name('volume_id'),
+            boot_index=10,
+            extra=self.extra)
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('da5c27d4-68cc-499f-b8ab-3048b87d3bca')
+    def test_create_volume_target_error(self):
+        """Create a volume target.
+
+        Fail when creating a volume target with same boot index as the
+        existing volume target.
+        """
+        regex_str = (r'.*A volume target .*already exists')
+
+        self.assertRaisesRegex(
+            lib_exc.Conflict, regex_str,
+            self.create_volume_target,
+            self.node['uuid'],
+            volume_type=data_utils.rand_name('volume_type'),
+            volume_id=data_utils.rand_name('volume_id'),
+            boot_index=self.volume_target['boot_index'])
+
+    @decorators.idempotent_id('ea3a9b2e-8971-4830-9274-abaf0239f1ce')
+    def test_delete_volume_target(self):
+        """Delete a volume target."""
+        # Powering off the Node before deleting a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.delete_volume_target(self.volume_target['uuid'])
+        self.assertRaises(lib_exc.NotFound, self.client.show_volume_target,
+                          self.volume_target['uuid'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('532a06bc-a9b2-44b0-828a-c53279c87cb2')
+    def test_delete_volume_target_error(self):
+        """Fail when deleting a volume target on node with power on state."""
+        # Powering on the Node before deleting a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power on')
+
+        regex_str = (r'.*The requested action \\\\"volume target '
+                     r'deletion\\\\" can not be performed on node*')
+
+        self.assertRaisesRegex(lib_exc.BadRequest,
+                               regex_str,
+                               self.delete_volume_target,
+                               self.volume_target['uuid'])
+
+    @decorators.idempotent_id('a2598388-8f61-4b7e-944f-f37e4f60e1e2')
+    def test_show_volume_target(self):
+        """Show a specified volume target."""
+        _, volume_target = self.client.show_volume_target(
+            self.volume_target['uuid'])
+        self._assertExpected(self.volume_target, volume_target)
+
+    @decorators.idempotent_id('ae99a986-d93c-4324-9cdc-41d89e3a659f')
+    def test_list_volume_targets(self):
+        """List volume targets."""
+        _, body = self.client.list_volume_targets()
+        self.assertIn(self.volume_target['uuid'],
+                      [i['uuid'] for i in body['targets']])
+        self.assertIn(self.volume_target['volume_type'],
+                      [i['volume_type'] for i in body['targets']])
+        self.assertIn(self.volume_target['volume_id'],
+                      [i['volume_id'] for i in body['targets']])
+
+    @decorators.idempotent_id('9da25447-0370-4b33-9c1f-d4503f5950ae')
+    def test_list_with_limit(self):
+        """List volume targets with limit."""
+        _, body = self.client.list_volume_targets(limit=3)
+
+        next_marker = body['targets'][-1]['uuid']
+        self.assertIn(next_marker, body['next'])
+
+    @decorators.idempotent_id('8559cd08-feae-4f1a-a0ad-5bad8ea12b76')
+    def test_update_volume_target_replace(self):
+        """Update a volume target by replacing volume id."""
+        new_volume_id = data_utils.rand_name('volume_id')
+
+        patch = [{'path': '/volume_id',
+                  'op': 'replace',
+                  'value': new_volume_id}]
+
+        # Powering off the Node before updating a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.client.update_volume_target(self.volume_target['uuid'], patch)
+
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        self.assertEqual(new_volume_id, body['volume_id'])
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('fd5266d3-4f3c-4dce-9c87-bfdea2b756c7')
+    def test_update_volume_target_replace_error(self):
+        """Fail when updating a volume target on node with power on state."""
+        new_volume_id = data_utils.rand_name('volume_id')
+
+        patch = [{'path': '/volume_id',
+                  'op': 'replace',
+                  'value': new_volume_id}]
+
+        # Powering on the Node before updating a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power on')
+
+        regex_str = (r'.*The requested action \\\\"volume target '
+                     r'update\\\\" can not be performed on node*')
+
+        self.assertRaisesRegex(lib_exc.BadRequest,
+                               regex_str,
+                               self.client.update_volume_target,
+                               self.volume_target['uuid'],
+                               patch)
+
+    @decorators.idempotent_id('1c13a4ee-1a49-4739-8c19-77960fbd1af8')
+    def test_update_volume_target_remove_item(self):
+        """Update a volume target by removing one item from the collection."""
+        new_extra = {'key1': 'value1'}
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        volume_id = body['volume_id']
+        volume_type = body['volume_type']
+
+        # Powering off the Node before updating a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        # Removing one item from the collection
+        self.client.update_volume_target(self.volume_target['uuid'],
+                                         [{'path': '/extra/key2',
+                                           'op': 'remove'}])
+
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        self.assertEqual(new_extra, body['extra'])
+
+        # Assert nothing else was changed
+        self.assertEqual(volume_id, body['volume_id'])
+        self.assertEqual(volume_type, body['volume_type'])
+
+    @decorators.idempotent_id('6784ddb0-9144-41ea-b8a0-f888ad5c5b62')
+    def test_update_volume_target_remove_collection(self):
+        """Update a volume target by removing the collection."""
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        volume_id = body['volume_id']
+        volume_type = body['volume_type']
+
+        # Powering off the Node before updating a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        # Removing the collection
+        self.client.update_volume_target(self.volume_target['uuid'],
+                                         [{'path': '/extra',
+                                           'op': 'remove'}])
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        self.assertEqual({}, body['extra'])
+
+        # Assert nothing else was changed
+        self.assertEqual(volume_id, body['volume_id'])
+        self.assertEqual(volume_type, body['volume_type'])
+
+    @decorators.idempotent_id('9629715d-57ba-423b-b985-232674cc3a25')
+    def test_update_volume_target_add(self):
+        """Update a volume target by adding to the collection."""
+        new_extra = {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}
+
+        patch = [{'path': '/extra/key3',
+                  'op': 'add',
+                  'value': new_extra['key3']}]
+
+        # Powering off the Node before updating a volume target.
+        self.client.set_node_power_state(self.node['uuid'], 'power off')
+
+        self.client.update_volume_target(self.volume_target['uuid'], patch)
+
+        _, body = self.client.show_volume_target(self.volume_target['uuid'])
+        self.assertEqual(new_extra, body['extra'])
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
new file mode 100644
index 0000000..bd6f1bd
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
@@ -0,0 +1,231 @@
+# Copyright 2012 OpenStack Foundation
+# Copyright 2013 IBM Corp.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import time
+
+from tempest.common import waiters
+from tempest import config
+from tempest.lib.common import api_version_utils
+from tempest.lib import exceptions as lib_exc
+
+from ironic_tempest_plugin import clients
+from ironic_tempest_plugin.common import utils
+from ironic_tempest_plugin.common import waiters as ironic_waiters
+from ironic_tempest_plugin import manager
+
+CONF = config.CONF
+
+
+def retry_on_conflict(func):
+    def inner(*args, **kwargs):
+        # TODO(vsaienko): make number of retries and delay between
+        # them configurable in future.
+        e = None
+        for att in range(10):
+            try:
+                return func(*args, **kwargs)
+            except lib_exc.Conflict as e:
+                time.sleep(1)
+        raise lib_exc.Conflict(e)
+
+    return inner
+
+
+# power/provision states as of icehouse
+class BaremetalPowerStates(object):
+    """Possible power states of an Ironic node."""
+    POWER_ON = 'power on'
+    POWER_OFF = 'power off'
+    REBOOT = 'rebooting'
+    SUSPEND = 'suspended'
+
+
+class BaremetalProvisionStates(object):
+    """Possible provision states of an Ironic node."""
+    ENROLL = 'enroll'
+    NOSTATE = None
+    AVAILABLE = 'available'
+    INIT = 'initializing'
+    ACTIVE = 'active'
+    BUILDING = 'building'
+    DEPLOYWAIT = 'wait call-back'
+    DEPLOYING = 'deploying'
+    DEPLOYFAIL = 'deploy failed'
+    DEPLOYDONE = 'deploy complete'
+    DELETING = 'deleting'
+    DELETED = 'deleted'
+    ERROR = 'error'
+    MANAGEABLE = 'manageable'
+
+
+class BaremetalScenarioTest(manager.ScenarioTest):
+
+    credentials = ['primary', 'admin']
+    min_microversion = None
+    max_microversion = api_version_utils.LATEST_MICROVERSION
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalScenarioTest, cls).skip_checks()
+        if not CONF.service_available.ironic:
+            raise cls.skipException('Ironic is not enabled.')
+        cfg_min_version = CONF.baremetal.min_microversion
+        cfg_max_version = CONF.baremetal.max_microversion
+        api_version_utils.check_skip_with_microversion(cls.min_microversion,
+                                                       cls.max_microversion,
+                                                       cfg_min_version,
+                                                       cfg_max_version)
+
+    @classmethod
+    def setup_clients(cls):
+        super(BaremetalScenarioTest, cls).setup_clients()
+
+        cls.baremetal_client = clients.Manager().baremetal_client
+
+    @classmethod
+    def resource_setup(cls):
+        super(BaremetalScenarioTest, cls).resource_setup()
+        # allow any issues obtaining the node list to raise early
+        cls.baremetal_client.list_nodes()
+
+    @classmethod
+    def wait_provisioning_state(cls, node_id, state, timeout=10, interval=1):
+        ironic_waiters.wait_for_bm_node_status(
+            cls.baremetal_client, node_id=node_id, attr='provision_state',
+            status=state, timeout=timeout, interval=interval)
+
+    @classmethod
+    def wait_power_state(cls, node_id, state):
+        ironic_waiters.wait_for_bm_node_status(
+            cls.baremetal_client, node_id=node_id, attr='power_state',
+            status=state, timeout=CONF.baremetal.power_timeout)
+
+    def wait_node(self, instance_id):
+        """Waits for a node to be associated with instance_id."""
+        ironic_waiters.wait_node_instance_association(self.baremetal_client,
+                                                      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_ports(self, node_uuid):
+        ports = []
+        _, body = self.baremetal_client.list_node_ports(node_uuid)
+        for port in body['ports']:
+            _, p = self.baremetal_client.show_port(port['uuid'])
+            ports.append(p)
+        return ports
+
+    def get_node_vifs(self, node_uuid, api_version='1.28'):
+        _, body = self.baremetal_client.vif_list(node_uuid,
+                                                 api_version=api_version)
+        return body['vifs']
+
+    def add_keypair(self):
+        self.keypair = self.create_keypair()
+
+    @classmethod
+    @retry_on_conflict
+    def update_node_driver(cls, node_id, driver):
+        _, body = cls.baremetal_client.update_node(
+            node_id, driver=driver)
+        return body
+
+    @classmethod
+    @retry_on_conflict
+    def update_node(cls, node_id, patch):
+        cls.baremetal_client.update_node(node_id, patch=patch)
+
+    @classmethod
+    @retry_on_conflict
+    def set_node_provision_state(cls, node_id, state, configdrive=None,
+                                 clean_steps=None):
+        cls.baremetal_client.set_node_provision_state(
+            node_id, state, configdrive=configdrive, clean_steps=clean_steps)
+
+    def verify_connectivity(self, ip=None):
+        if ip:
+            dest = self.get_remote_client(ip)
+        else:
+            dest = self.get_remote_client(self.instance)
+        dest.validate_authentication()
+
+    def boot_instance(self, clients=None, keypair=None,
+                      net_id=None, fixed_ip=None, **create_kwargs):
+        if clients is None:
+            servers_client = self.servers_client
+        else:
+            servers_client = clients.servers_client
+        if keypair is None:
+            keypair = self.keypair
+
+        if any([net_id, fixed_ip]):
+            network = {}
+            if net_id:
+                network['uuid'] = net_id
+            if fixed_ip:
+                network['fixed_ip'] = fixed_ip
+            instance = self.create_server(
+                key_name=keypair['name'],
+                networks=[network],
+                clients=clients,
+                **create_kwargs
+            )
+        else:
+            instance = self.create_server(
+                key_name=keypair['name'],
+                clients=clients,
+                **create_kwargs
+            )
+
+        self.wait_node(instance['id'])
+        node = self.get_node(instance_id=instance['id'])
+
+        self.wait_power_state(node['uuid'], BaremetalPowerStates.POWER_ON)
+
+        self.wait_provisioning_state(
+            node['uuid'],
+            [BaremetalProvisionStates.DEPLOYWAIT,
+             BaremetalProvisionStates.ACTIVE],
+            timeout=CONF.baremetal.deploywait_timeout)
+
+        self.wait_provisioning_state(node['uuid'],
+                                     BaremetalProvisionStates.ACTIVE,
+                                     timeout=CONF.baremetal.active_timeout,
+                                     interval=30)
+
+        waiters.wait_for_server_status(servers_client,
+                                       instance['id'], 'ACTIVE')
+        node = self.get_node(instance_id=instance['id'])
+        instance = servers_client.show_server(instance['id'])['server']
+
+        return instance, node
+
+    def terminate_instance(self, instance, servers_client=None):
+        if servers_client is None:
+            servers_client = self.servers_client
+
+        node = self.get_node(instance_id=instance['id'])
+        servers_client.delete_server(instance['id'])
+        self.wait_power_state(node['uuid'],
+                              BaremetalPowerStates.POWER_OFF)
+        self.wait_provisioning_state(
+            node['uuid'],
+            [BaremetalProvisionStates.NOSTATE,
+             BaremetalProvisionStates.AVAILABLE],
+            timeout=CONF.baremetal.unprovision_timeout,
+            interval=30)
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
new file mode 100644
index 0000000..1d19c47
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_standalone_manager.py
@@ -0,0 +1,339 @@
+#
+#    Copyright 2017 Mirantis Inc.
+#
+#    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 random
+
+from oslo_utils import uuidutils
+from tempest import config
+from tempest.lib.common.utils import test_utils
+from tempest.lib import exceptions as lib_exc
+from tempest.scenario import manager
+
+from ironic_tempest_plugin.services.baremetal import base
+from ironic_tempest_plugin.tests.scenario import baremetal_manager as bm
+
+CONF = config.CONF
+
+
+class BaremetalStandaloneManager(bm.BaremetalScenarioTest,
+                                 manager.NetworkScenarioTest):
+
+    credentials = ['primary', 'admin']
+    # NOTE(vsaienko): Standalone tests are using v1/node/<node_ident>/vifs to
+    # attach VIF to a node.
+    min_microversion = '1.28'
+
+    @classmethod
+    def skip_checks(cls):
+        """Defines conditions to skip these tests."""
+        super(BaremetalStandaloneManager, cls).skip_checks()
+        if CONF.service_available.nova:
+            raise cls.skipException('Nova is enabled. Stand-alone tests will '
+                                    'be skipped.')
+
+    @classmethod
+    def create_networks(cls):
+        """Create a network with a subnet connected to a router.
+
+        Return existed network specified in compute/fixed_network_name
+        config option.
+        TODO(vsaienko): Add network/subnet/router when we setup
+        ironic-standalone with multitenancy.
+
+        :returns: network, subnet, router
+        """
+        network = None
+        subnet = None
+        router = None
+        if CONF.network.shared_physical_network:
+            if not CONF.compute.fixed_network_name:
+                m = ('Configuration option "[compute]/fixed_network_name" '
+                     'must be set.')
+                raise lib_exc.InvalidConfiguration(m)
+            network = cls.os_admin.networks_client.list_networks(
+                name=CONF.compute.fixed_network_name)['networks'][0]
+        return network, subnet, router
+
+    @classmethod
+    def get_available_nodes(cls):
+        """Get all ironic nodes that can be deployed.
+
+        We can deploy on nodes when the following conditions are met:
+          * provision_state is 'available'
+          * maintenance is False
+          * No instance_uuid is associated to node.
+
+        :returns: a list of Ironic nodes.
+        """
+        fields = ['uuid', 'driver', 'instance_uuid', 'provision_state',
+                  'name', 'maintenance']
+        _, body = cls.baremetal_client.list_nodes(provision_state='available',
+                                                  associated=False,
+                                                  maintenance=False,
+                                                  fields=','.join(fields))
+        return body['nodes']
+
+    @classmethod
+    def get_random_available_node(cls):
+        """Randomly pick an available node for deployment."""
+        nodes = cls.get_available_nodes()
+        if nodes:
+            return random.choice(nodes)
+
+    @classmethod
+    def create_neutron_port(cls, *args, **kwargs):
+        """Creates a neutron port.
+
+        For a full list of available parameters, please refer to the official
+        API reference:
+        http://developer.openstack.org/api-ref/networking/v2/index.html#create-port
+
+        :returns: server response body.
+        """
+        port = cls.ports_client.create_port(*args, **kwargs)['port']
+        return port
+
+    @classmethod
+    def _associate_instance_with_node(cls, node_id, instance_uuid):
+        """Update instance_uuid for a given node.
+
+        :param node_id: Name or UUID of the node.
+        :param instance_uuid: UUID of the instance to associate.
+        :returns: server response body.
+        """
+        _, body = cls.baremetal_client.update_node(
+            node_id, instance_uuid=instance_uuid)
+        return body
+
+    @classmethod
+    def get_node_vifs(cls, node_id):
+        """Return a list of VIFs for a given node.
+
+        :param node_id: Name or UUID of the node.
+        :returns: A list of VIFs associated with the node.
+        """
+        _, body = cls.baremetal_client.vif_list(node_id)
+        vifs = [v['id'] for v in body['vifs']]
+        return vifs
+
+    @classmethod
+    def add_floatingip_to_node(cls, node_id):
+        """Add floating IP to node.
+
+        Create and associate floating IP with node VIF.
+
+        :param node_id: Name or UUID of the node.
+        :returns: IP address of associated floating IP.
+        """
+        vif = cls.get_node_vifs(node_id)[0]
+        body = cls.floating_ips_client.create_floatingip(
+            floating_network_id=CONF.network.public_network_id)
+        floating_ip = body['floatingip']
+        cls.floating_ips_client.update_floatingip(floating_ip['id'],
+                                                  port_id=vif)
+        return floating_ip['floating_ip_address']
+
+    @classmethod
+    def cleanup_floating_ip(cls, ip_address):
+        """Removes floating IP."""
+        body = cls.os_admin.floating_ips_client.list_floatingips()
+        floating_ip_id = [f['id'] for f in body['floatingips'] if
+                          f['floating_ip_address'] == ip_address][0]
+        cls.os_admin.floating_ips_client.delete_floatingip(floating_ip_id)
+
+    @classmethod
+    @bm.retry_on_conflict
+    def detach_all_vifs_from_node(cls, node_id):
+        """Detach all VIFs from a given node.
+
+        :param node_id: Name or UUID of the node.
+        """
+        vifs = cls.get_node_vifs(node_id)
+        for vif in vifs:
+            cls.baremetal_client.vif_detach(node_id, vif)
+
+    @classmethod
+    @bm.retry_on_conflict
+    def vif_attach(cls, node_id, vif_id):
+        """Attach VIF to a give node.
+
+        :param node_id: Name or UUID of the node.
+        :param vif_id: Identifier of the VIF to attach.
+        """
+        cls.baremetal_client.vif_attach(node_id, vif_id)
+
+    @classmethod
+    def get_and_reserve_node(cls, node=None):
+        """Pick an available node for deployment and reserve it.
+
+        Only one instance_uuid may be associated, use this behaviour as
+        reservation node when tests are launched concurrently. If node is
+        not passed directly pick random available for deployment node.
+
+        :param node: Ironic node to associate instance_uuid with.
+        :returns: Ironic node.
+        """
+        instance_uuid = uuidutils.generate_uuid()
+        nodes = []
+
+        def _try_to_associate_instance():
+            n = node or cls.get_random_available_node()
+            try:
+                cls._associate_instance_with_node(n['uuid'], instance_uuid)
+                nodes.append(n)
+            except lib_exc.Conflict:
+                return False
+            return True
+
+        if (not test_utils.call_until_true(_try_to_associate_instance,
+            duration=CONF.baremetal.association_timeout, sleep_for=1)):
+            msg = ('Timed out waiting to associate instance to ironic node '
+                   'uuid %s' % instance_uuid)
+            raise lib_exc.TimeoutException(msg)
+
+        return nodes[0]
+
+    @classmethod
+    def boot_node(cls, driver, image_ref, image_checksum=None):
+        """Boot ironic node.
+
+        The following actions are executed:
+          * Randomly pick an available node for deployment and reserve it.
+          * Update node driver.
+          * Create/Pick networks to boot node in.
+          * Create Neutron port and attach it to node.
+          * Update node image_source/root_gb.
+          * Deploy node.
+          * Wait until node is deployed.
+
+        :param driver: Node driver to use.
+        :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.
+        :returns: Ironic node.
+        """
+        node = cls.get_and_reserve_node()
+        cls.update_node_driver(node['uuid'], driver)
+        network, subnet, router = cls.create_networks()
+        n_port = cls.create_neutron_port(network_id=network['id'])
+        cls.vif_attach(node_id=node['uuid'], vif_id=n_port['id'])
+        patch = [{'path': '/instance_info/image_source',
+                  'op': 'add',
+                  'value': image_ref}]
+        if image_checksum is not None:
+            patch.append({'path': '/instance_info/image_checksum',
+                          'op': 'add',
+                          'value': image_checksum})
+        patch.append({'path': '/instance_info/root_gb',
+                      'op': 'add',
+                      'value': CONF.baremetal.adjusted_root_disk_size_gb})
+        # TODO(vsaienko) add testing for custom configdrive
+        cls.update_node(node['uuid'], patch=patch)
+        cls.set_node_provision_state(node['uuid'], 'active')
+        cls.wait_power_state(node['uuid'], bm.BaremetalPowerStates.POWER_ON)
+        cls.wait_provisioning_state(node['uuid'],
+                                    bm.BaremetalProvisionStates.ACTIVE,
+                                    timeout=CONF.baremetal.active_timeout,
+                                    interval=30)
+        return node
+
+    @classmethod
+    def terminate_node(cls, node_id):
+        """Terminate active ironic node.
+
+        The following actions are executed:
+           * Detach all VIFs from the given node.
+           * Unprovision node.
+           * Wait until node become available.
+
+        :param node_id: Name or UUID for the node.
+        """
+        cls.detach_all_vifs_from_node(node_id)
+        cls.set_node_provision_state(node_id, 'deleted')
+        # NOTE(vsaienko) We expect here fast switching from deleted to
+        # available as automated cleaning is disabled so poll status each 1s.
+        cls.wait_provisioning_state(
+            node_id,
+            [bm.BaremetalProvisionStates.NOSTATE,
+             bm.BaremetalProvisionStates.AVAILABLE],
+            timeout=CONF.baremetal.unprovision_timeout,
+            interval=1)
+
+
+class BaremetalStandaloneScenarioTest(BaremetalStandaloneManager):
+
+    # API microversion to use among all calls
+    api_microversion = '1.28'
+
+    # The node driver to use in the test
+    driver = None
+
+    # User image ref to boot node with.
+    image_ref = None
+
+    # Boolean value specify if image is wholedisk or not.
+    wholedisk_image = None
+
+    # Image checksum, required when image is stored on HTTP server.
+    image_checksum = None
+
+    mandatory_attr = ['driver', 'image_ref', 'wholedisk_image']
+
+    node = None
+    node_ip = None
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalStandaloneScenarioTest, cls).skip_checks()
+        if (cls.driver not in CONF.baremetal.enabled_drivers +
+                CONF.baremetal.enabled_hardware_types):
+            raise cls.skipException(
+                'The driver: %(driver)s used in test is not in the list of '
+                'enabled_drivers %(enabled_drivers)s or '
+                'enabled_hardware_types %(enabled_hw_types)s '
+                'in the tempest config.' % {
+                    'driver': cls.driver,
+                    'enabled_drivers': CONF.baremetal.enabled_drivers,
+                    'enabled_hw_types': CONF.baremetal.enabled_hardware_types})
+        if not cls.wholedisk_image and CONF.baremetal.use_provision_network:
+            raise cls.skipException(
+                'Partitioned images are not supported with multitenancy.')
+
+    @classmethod
+    def resource_setup(cls):
+        super(BaremetalStandaloneScenarioTest, cls).resource_setup()
+        base.set_baremetal_api_microversion(cls.api_microversion)
+        for v in cls.mandatory_attr:
+            if getattr(cls, v) is None:
+                raise lib_exc.InvalidConfiguration(
+                    "Mandatory attribute %s not set." % v)
+        image_checksum = None
+        if not uuidutils.is_uuid_like(cls.image_ref):
+            image_checksum = cls.image_checksum
+        cls.node = cls.boot_node(cls.driver, cls.image_ref,
+                                 image_checksum=image_checksum)
+        cls.node_ip = cls.add_floatingip_to_node(cls.node['uuid'])
+
+    @classmethod
+    def resource_cleanup(cls):
+        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.
+        for vif in vifs:
+            cls.ports_client.delete_port(vif)
+        cls.terminate_node(cls.node['uuid'])
+        base.reset_baremetal_api_microversion()
+        super(BaremetalStandaloneManager, cls).resource_cleanup()
diff --git a/ironic_tempest_plugin/tests/scenario/ironic_standalone/__init__.py b/ironic_tempest_plugin/tests/scenario/ironic_standalone/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/__init__.py
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
new file mode 100644
index 0000000..f6e02e1
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/ironic_standalone/test_basic_ops.py
@@ -0,0 +1,145 @@
+#
+# Copyright 2017 Mirantis Inc.
+#
+# 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.common import utils
+from tempest import config
+from tempest.lib import decorators
+
+from ironic_tempest_plugin.tests.scenario import \
+    baremetal_standalone_manager as bsm
+
+CONF = config.CONF
+
+
+class BaremetalAgentIpmitoolWholedisk(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'agent_ipmitool'
+    image_ref = CONF.baremetal.whole_disk_image_ref
+    wholedisk_image = True
+
+    @decorators.idempotent_id('defff515-a6ff-44f6-9d8d-2ded51196d98')
+    @utils.services('image', 'network', 'object_storage')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalAgentIpmitoolWholediskHttpLink(
+        bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'agent_ipmitool'
+    image_ref = CONF.baremetal.whole_disk_image_url
+    image_checksum = CONF.baremetal.whole_disk_image_checksum
+    wholedisk_image = True
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalAgentIpmitoolWholediskHttpLink, cls).skip_checks()
+        if not CONF.baremetal_feature_enabled.ipxe_enabled:
+            skip_msg = ("HTTP server is not available when ipxe is disabled.")
+            raise cls.skipException(skip_msg)
+
+    @decorators.idempotent_id('d926c683-1a32-44df-afd0-e60134346fd0')
+    @utils.services('network')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalAgentIpmitoolPartitioned(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'agent_ipmitool'
+    image_ref = CONF.baremetal.partition_image_ref
+    wholedisk_image = False
+
+    @decorators.idempotent_id('27b86130-d8dc-419d-880a-fbbbe4ce3f8c')
+    @utils.services('image', 'network', 'object_storage')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalPxeIpmitoolWholedisk(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'pxe_ipmitool'
+    image_ref = CONF.baremetal.whole_disk_image_ref
+    wholedisk_image = True
+
+    @decorators.idempotent_id('d8c5badd-45db-4d05-bbe8-35babbed6e86')
+    @utils.services('image', 'network')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalPxeIpmitoolWholediskHttpLink(
+        bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'pxe_ipmitool'
+    image_ref = CONF.baremetal.whole_disk_image_url
+    image_checksum = CONF.baremetal.whole_disk_image_checksum
+    wholedisk_image = True
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalPxeIpmitoolWholediskHttpLink, cls).skip_checks()
+        if not CONF.baremetal_feature_enabled.ipxe_enabled:
+            skip_msg = ("HTTP server is not available when ipxe is disabled.")
+            raise cls.skipException(skip_msg)
+
+    @decorators.idempotent_id('71ccf06f-6765-40fd-8252-1b1bfa423b9b')
+    @utils.services('network')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalPxeIpmitoolPartitioned(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'pxe_ipmitool'
+    image_ref = CONF.baremetal.partition_image_ref
+    wholedisk_image = False
+
+    @decorators.idempotent_id('ea85e19c-6869-4577-b9bb-2eb150f77c90')
+    @utils.services('image', 'network')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalIpmiWholedisk(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'ipmi'
+    image_ref = CONF.baremetal.whole_disk_image_ref
+    wholedisk_image = True
+
+    @decorators.idempotent_id('c2db24e7-07dc-4a20-8f93-d4efae2bfd4e')
+    @utils.services('image', 'network')
+    def test_ip_access_to_server(self):
+        self.assertTrue(self.ping_ip_address(self.node_ip,
+                                             should_succeed=True))
+
+
+class BaremetalIpmiPartitioned(bsm.BaremetalStandaloneScenarioTest):
+
+    driver = 'ipmi'
+    image_ref = CONF.baremetal.partition_image_ref
+    wholedisk_image = False
+
+    @decorators.idempotent_id('7d0b205e-edbc-4e2d-9f6d-95cd74eefecb')
+    @utils.services('image', 'network')
+    def test_ip_access_to_server(self):
+        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
new file mode 100644
index 0000000..47fc07e
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
@@ -0,0 +1,146 @@
+#
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+#
+# 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_log import log as logging
+from tempest.common import utils
+from tempest.common import waiters
+from tempest import config
+from tempest.lib.common import api_version_request
+from tempest.lib import decorators
+
+from ironic_tempest_plugin.tests.scenario import baremetal_manager
+
+LOG = logging.getLogger(__name__)
+CONF = config.CONF
+
+
+class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest):
+    """This smoke test tests the pxe_ssh Ironic driver.
+
+    It follows this basic set of operations:
+        * Creates a keypair
+        * Boots an instance using the keypair
+        * Monitors the associated Ironic node for power and
+          expected state transitions
+        * Validates Ironic node's port data has been properly updated
+        * Verifies SSH connectivity using created keypair via fixed IP
+        * Associates a floating ip
+        * Verifies SSH connectivity using created keypair via floating IP
+        * Verifies instance rebuild with ephemeral partition preservation
+        * Deletes instance
+        * Monitors the associated Ironic node for power and
+          expected state transitions
+    """
+
+    def rebuild_instance(self, preserve_ephemeral=False):
+        self.rebuild_server(server_id=self.instance['id'],
+                            preserve_ephemeral=preserve_ephemeral,
+                            wait=False)
+
+        node = self.get_node(instance_id=self.instance['id'])
+
+        # We should remain on the same node
+        self.assertEqual(self.node['uuid'], node['uuid'])
+        self.node = node
+
+        waiters.wait_for_server_status(
+            self.servers_client,
+            server_id=self.instance['id'],
+            status='REBUILD',
+            ready_wait=False)
+        waiters.wait_for_server_status(
+            self.servers_client,
+            server_id=self.instance['id'],
+            status='ACTIVE')
+
+    def verify_partition(self, client, label, mount, gib_size):
+        """Verify a labeled partition's mount point and size."""
+        LOG.info("Looking for partition %s mounted on %s", label, mount)
+
+        # Validate we have a device with the given partition label
+        cmd = "/sbin/blkid | grep '%s' | cut -d':' -f1" % label
+        device = client.exec_command(cmd).rstrip('\n')
+        LOG.debug("Partition device is %s", device)
+        self.assertNotEqual('', device)
+
+        # Validate the mount point for the device
+        cmd = "mount | grep '%s' | cut -d' ' -f3" % device
+        actual_mount = client.exec_command(cmd).rstrip('\n')
+        LOG.debug("Partition mount point is %s", actual_mount)
+        self.assertEqual(actual_mount, mount)
+
+        # Validate the partition size matches what we expect
+        numbers = '0123456789'
+        devnum = device.replace('/dev/', '')
+        cmd = "cat /sys/block/%s/%s/size" % (devnum.rstrip(numbers), devnum)
+        num_bytes = client.exec_command(cmd).rstrip('\n')
+        num_bytes = int(num_bytes) * 512
+        actual_gib_size = num_bytes / (1024 * 1024 * 1024)
+        LOG.debug("Partition size is %d GiB", actual_gib_size)
+        self.assertEqual(actual_gib_size, gib_size)
+
+    def get_flavor_ephemeral_size(self):
+        """Returns size of the ephemeral partition in GiB."""
+        f_id = self.instance['flavor']['id']
+        flavor = self.flavors_client.show_flavor(f_id)['flavor']
+        ephemeral = flavor.get('OS-FLV-EXT-DATA:ephemeral')
+        if not ephemeral or ephemeral == 'N/A':
+            return None
+        return int(ephemeral)
+
+    def validate_ports(self):
+        node_uuid = self.node['uuid']
+        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')):
+            vifs = self.get_node_vifs(node_uuid)
+        else:
+            for port in self.get_ports(self.node['uuid']):
+                vif = port['extra'].get('vif_port_id')
+                if vif:
+                    vifs.append({'id': vif})
+
+        ir_ports = self.get_ports(node_uuid)
+        ir_ports_addresses = [x['address'] for x in ir_ports]
+        for vif in vifs:
+            n_port_id = vif['id']
+            body = self.ports_client.show_port(n_port_id)
+            n_port = body['port']
+            self.assertEqual(n_port['device_id'], self.instance['id'])
+            self.assertIn(n_port['mac_address'], ir_ports_addresses)
+
+    @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()
+        ip_address = self.get_server_ip(self.instance)
+        self.get_remote_client(ip_address).validate_authentication()
+        vm_client = self.get_remote_client(ip_address)
+
+        # We expect the ephemeral partition to be mounted on /mnt and to have
+        # the same size as our flavor definition.
+        eph_size = self.get_flavor_ephemeral_size()
+        if eph_size:
+            self.verify_partition(vm_client, 'ephemeral0', '/mnt', eph_size)
+            # Create the test file
+            self.create_timestamp(
+                ip_address, private_key=self.keypair['private_key'])
+
+        self.terminate_instance(self.instance)
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_boot_from_volume.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_boot_from_volume.py
new file mode 100644
index 0000000..9908270
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_boot_from_volume.py
@@ -0,0 +1,152 @@
+# Copyright 2017 FUJITSU LIMITED
+#
+# 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.common import utils
+from tempest.common import waiters
+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.tests.scenario import baremetal_manager
+
+CONF = config.CONF
+
+
+class BaremetalBFV(baremetal_manager.BaremetalScenarioTest):
+    """Check baremetal instance that can boot from Cinder volume:
+
+    * Create a volume from an image
+    * Create a keypair
+    * Boot an instance from the volume using the keypair
+    * Verify instance IP address
+    * Delete instance
+    """
+
+    credentials = ['primary', 'admin']
+
+    min_microversion = '1.32'
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalBFV, cls).skip_checks()
+        if CONF.baremetal.use_provision_network:
+            msg = 'Ironic boot-from-volume requires a flat network.'
+            raise cls.skipException(msg)
+
+    def create_volume(self, size=None, name=None, snapshot_id=None,
+                      image_id=None, volume_type=None):
+        if size is None:
+            size = CONF.volume.volume_size
+        if image_id is None:
+            image = self.compute_images_client.show_image(image_id)['image']
+            min_disk = image.get('minDisk')
+            size = max(size, min_disk)
+        if name is None:
+            name = data_utils.rand_name(self.__class__.__name__ + "-volume")
+        kwargs = {'display_name': name,
+                  'snapshot_id': snapshot_id,
+                  'imageRef': image_id,
+                  'volume_type': volume_type,
+                  'size': size}
+        volume = self.volumes_client.create_volume(**kwargs)['volume']
+
+        self.addCleanup(self.volumes_client.wait_for_resource_deletion,
+                        volume['id'])
+        self.addCleanup(test_utils.call_and_ignore_notfound_exc,
+                        self.volumes_client.delete_volume, volume['id'])
+        self.assertEqual(name, volume['name'])
+        waiters.wait_for_volume_resource_status(self.volumes_client,
+                                                volume['id'], 'available')
+        # The volume retrieved on creation has a non-up-to-date status.
+        # Retrieval after it becomes active ensures correct details.
+        volume = self.volumes_client.show_volume(volume['id'])['volume']
+        return volume
+
+    def _create_volume_from_image(self):
+        """Create a cinder volume from the default image."""
+        image_id = CONF.compute.image_ref
+        vol_name = data_utils.rand_name(
+            self.__class__.__name__ + '-volume-origin')
+        return self.create_volume(name=vol_name, image_id=image_id)
+
+    def _get_bdm(self, source_id, source_type, delete_on_termination=False):
+        """Create block device mapping config options dict.
+
+        :param source_id: id of the source device.
+        :param source_type: type of the source device.
+        :param delete_on_termination: what to do with the volume when
+          the instance is terminated.
+        :return: a dictionary of configuration options for block
+          device mapping.
+        """
+        bd_map_v2 = [{
+            'uuid': source_id,
+            'source_type': source_type,
+            'destination_type': 'volume',
+            'boot_index': 0,
+            'delete_on_termination': delete_on_termination}]
+        return {'block_device_mapping_v2': bd_map_v2}
+
+    def _boot_instance_from_resource(self, source_id,
+                                     source_type,
+                                     keypair=None,
+                                     delete_on_termination=False):
+        """Boot instance from the specified resource."""
+        # NOTE(tiendc): Boot to the volume, use image_id=''.
+        # We don't pass image_id=None as that will cause the default image
+        # to be used.
+        create_kwargs = {'image_id': ''}
+        create_kwargs.update(self._get_bdm(
+            source_id,
+            source_type,
+            delete_on_termination=delete_on_termination))
+
+        return self.boot_instance(
+            clients=self.os_primary,
+            keypair=keypair,
+            **create_kwargs
+        )
+
+    @decorators.idempotent_id('d6e05e61-8221-44ac-b785-57545f8e0fcf')
+    @utils.services('compute', 'image', 'network', 'volume')
+    def test_baremetal_boot_from_volume(self):
+        """Test baremetal node can boot from a cinder volume.
+
+        This test function first creates a cinder volume from an image.
+        Then it executes "server create" action with appropriate block
+        device mapping config options, the baremetal node will boot
+        from the newly created volume. This requires a volume connector
+        is created for the node, and the node capabilities flag
+        iscsi_boot is set to true.
+        """
+        # Create volume from image
+        volume_origin = self._create_volume_from_image()
+
+        # NOTE: node properties/capabilities for flag iscsi_boot=true,
+        # and volume connector should be added by devstack already.
+
+        # Boot instance
+        self.keypair = self.create_keypair()
+        self.instance, self.node = self._boot_instance_from_resource(
+            source_id=volume_origin['id'],
+            source_type='volume',
+            keypair=self.keypair
+        )
+
+        # Get server ip and validate authentication
+        ip_address = self.get_server_ip(self.instance)
+        self.get_remote_client(ip_address).validate_authentication()
+
+        self.terminate_instance(instance=self.instance)
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
new file mode 100644
index 0000000..013eb2e
--- /dev/null
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_multitenancy.py
@@ -0,0 +1,153 @@
+#
+# Copyright (c) 2015 Mirantis, Inc.
+#
+# 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.common import utils
+from tempest import config
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+
+from ironic_tempest_plugin import manager
+from ironic_tempest_plugin.tests.scenario import baremetal_manager
+
+CONF = config.CONF
+
+
+class BaremetalMultitenancy(baremetal_manager.BaremetalScenarioTest,
+                            manager.NetworkScenarioTest):
+    """Check L2 isolation of baremetal instances in different tenants:
+
+    * Create a keypair, network, subnet and router for the primary tenant
+    * Boot 2 instances in the different tenant's network using the keypair
+    * Associate floating ips to both instance
+    * Verify there is no L3 connectivity between instances of different tenants
+    * Verify connectivity between instances floating IP's
+    * Delete both instances
+    """
+
+    credentials = ['primary', 'alt', 'admin']
+
+    @classmethod
+    def skip_checks(cls):
+        super(BaremetalMultitenancy, cls).skip_checks()
+        if not CONF.baremetal.use_provision_network:
+            msg = 'Ironic/Neutron tenant isolation is not configured.'
+            raise cls.skipException(msg)
+
+    def create_tenant_network(self, clients, tenant_cidr):
+        network = self._create_network(
+            networks_client=clients.networks_client,
+            tenant_id=clients.credentials.tenant_id)
+        router = self._get_router(
+            client=clients.routers_client,
+            tenant_id=clients.credentials.tenant_id)
+
+        result = clients.subnets_client.create_subnet(
+            name=data_utils.rand_name('subnet'),
+            network_id=network['id'],
+            tenant_id=clients.credentials.tenant_id,
+            ip_version=4,
+            cidr=tenant_cidr)
+        subnet = result['subnet']
+        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'])
+
+        return network, subnet, router
+
+    def verify_l3_connectivity(self, source_ip, private_key,
+                               destination_ip, conn_expected=True):
+        remote = self.get_remote_client(source_ip, private_key=private_key)
+        remote.validate_authentication()
+
+        cmd = 'ping %s -c4 -w4 || exit 0' % destination_ip
+        success_substring = "64 bytes from %s" % destination_ip
+        output = remote.exec_command(cmd)
+        if conn_expected:
+            self.assertIn(success_substring, output)
+        else:
+            self.assertNotIn(success_substring, output)
+
+    @decorators.idempotent_id('26e2f145-2a8e-4dc7-8457-7f2eb2c6749d')
+    @utils.services('compute', 'image', 'network')
+    def test_baremetal_multitenancy(self):
+
+        tenant_cidr = '10.0.100.0/24'
+        fixed_ip1 = '10.0.100.3'
+        fixed_ip2 = '10.0.100.5'
+        keypair = self.create_keypair()
+        network, subnet, router = self.create_tenant_network(
+            self.os_primary, tenant_cidr)
+
+        # Boot 2 instances in the primary tenant network
+        # and check L2 connectivity between them
+        instance1, node1 = self.boot_instance(
+            clients=self.os_primary,
+            keypair=keypair,
+            net_id=network['id'],
+            fixed_ip=fixed_ip1
+        )
+        floating_ip1 = self.create_floating_ip(
+            instance1,
+        )['floating_ip_address']
+        self.check_vm_connectivity(ip_address=floating_ip1,
+                                   private_key=keypair['private_key'])
+
+        # Boot instance in the alt tenant network and ensure there is no
+        # L2 connectivity between instances of the different tenants
+        alt_keypair = self.create_keypair(self.os_alt.keypairs_client)
+        alt_network, alt_subnet, alt_router = self.create_tenant_network(
+            self.os_alt, tenant_cidr)
+
+        alt_instance, alt_node = self.boot_instance(
+            keypair=alt_keypair,
+            clients=self.os_alt,
+            net_id=alt_network['id'],
+            fixed_ip=fixed_ip2
+        )
+        alt_floating_ip = self.create_floating_ip(
+            alt_instance,
+            client=self.os_alt.floating_ips_client
+        )['floating_ip_address']
+
+        self.check_vm_connectivity(ip_address=alt_floating_ip,
+                                   private_key=alt_keypair['private_key'])
+
+        self.verify_l3_connectivity(
+            alt_floating_ip,
+            alt_keypair['private_key'],
+            fixed_ip1,
+            conn_expected=False
+        )
+
+        self.verify_l3_connectivity(
+            floating_ip1,
+            keypair['private_key'],
+            fixed_ip2,
+            conn_expected=False
+        )
+
+        self.verify_l3_connectivity(
+            floating_ip1,
+            keypair['private_key'],
+            alt_floating_ip,
+            conn_expected=True
+        )
+
+        self.terminate_instance(
+            instance=alt_instance,
+            servers_client=self.os_alt.servers_client)
+        self.terminate_instance(instance=instance1)