Merge remote-tracking branch 'inspector/master'
diff --git a/ironic_tempest_plugin/README.rst b/ironic_tempest_plugin/README.rst
index a46e206..6de7741 100644
--- a/ironic_tempest_plugin/README.rst
+++ b/ironic_tempest_plugin/README.rst
@@ -2,8 +2,8 @@
 Ironic tempest plugin
 =====================
 
-This directory contains Tempest tests to cover the Ironic project,
-as well as a plugin to automatically load these tests into tempest.
+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.
 
 See the tempest plugin documentation for information about creating
 a plugin, stable API interface, TempestPlugin class interface, plugin
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index 661b89c..aa416b8 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -18,11 +18,15 @@
 from tempest import config  # noqa
 
 
-service_option = cfg.BoolOpt('ironic',
-                             default=False,
-                             help='Whether or not Ironic is expected to be '
-                                  'available')
+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',
@@ -34,6 +38,12 @@
                                     'live_migration, pause, rescue, resize, '
                                     'shelve, snapshot, and suspend')
 
+baremetal_introspection_group = cfg.OptGroup(
+    name="baremetal_introspection",
+    title="Baremetal introspection service options",
+    help="When enabling baremetal introspection tests,"
+         "Ironic must be configured.")
+
 baremetal_features_group = cfg.OptGroup(
     name='baremetal_feature_enabled',
     title="Enabled Baremetal Service Features")
@@ -114,3 +124,45 @@
                 default=True,
                 help="Defines if IPXE is enabled"),
 ]
+
+BaremetalIntrospectionGroup = [
+    cfg.StrOpt('catalog_type',
+               default='baremetal-introspection',
+               help="Catalog type of the baremetal provisioning service"),
+    cfg.StrOpt('endpoint_type',
+               default='publicURL',
+               choices=['public', 'admin', 'internal',
+                        'publicURL', 'adminURL', 'internalURL'],
+               help="The endpoint type to use for the baremetal introspection"
+                    " service"),
+    cfg.IntOpt('introspection_sleep',
+               default=30,
+               help="Introspection sleep before check status"),
+    cfg.IntOpt('introspection_timeout',
+               default=600,
+               help="Introspection time out"),
+    cfg.IntOpt('hypervisor_update_sleep',
+               default=60,
+               help="Time to wait until nova becomes aware of "
+                    "bare metal instances"),
+    cfg.IntOpt('hypervisor_update_timeout',
+               default=300,
+               help="Time out for wait until nova becomes aware of "
+                    "bare metal instances"),
+    # NOTE(aarefiev): status_check_period default is 60s, but checking
+    # node state takes some time(API call), so races appear here,
+    # 80s would be enough to make one more check.
+    cfg.IntOpt('ironic_sync_timeout',
+               default=80,
+               help="Time it might take for Ironic--Inspector "
+                    "sync to happen"),
+    cfg.IntOpt('discovery_timeout',
+               default=300,
+               help="Time to wait until new node would enrolled in "
+                    "ironic"),
+    cfg.BoolOpt('auto_discovery_feature',
+                default=False,
+                help="Is the auto-discovery feature enabled. Enroll hook "
+                     "should be specified in node_not_found_hook - processing "
+                     "section of inspector.conf"),
+]
diff --git a/ironic_tempest_plugin/exceptions.py b/ironic_tempest_plugin/exceptions.py
new file mode 100644
index 0000000..ac08d54
--- /dev/null
+++ b/ironic_tempest_plugin/exceptions.py
@@ -0,0 +1,25 @@
+#    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 exceptions
+
+
+class IntrospectionFailed(exceptions.TempestException):
+    message = "Introspection failed"
+
+
+class IntrospectionTimeout(exceptions.TempestException):
+    message = "Introspection time out"
+
+
+class HypervisorUpdateTimeout(exceptions.TempestException):
+    message = "Hypervisor stats update time out"
diff --git a/ironic_tempest_plugin/plugin.py b/ironic_tempest_plugin/plugin.py
index 9e9c175..e20386b 100644
--- a/ironic_tempest_plugin/plugin.py
+++ b/ironic_tempest_plugin/plugin.py
@@ -25,6 +25,8 @@
     (project_config.baremetal_group, project_config.BaremetalGroup),
     (project_config.baremetal_features_group,
      project_config.BaremetalFeaturesGroup)
+    (project_config.baremetal_introspection_group,
+     project_config.BaremetalIntrospectionGroup),
 ]
 
 
@@ -37,7 +39,9 @@
         return full_test_dir, base_path
 
     def register_opts(self, conf):
-        conf.register_opt(project_config.service_option,
+        conf.register_opt(project_config.ironic_service_option,
+                          group='service_available')
+        conf.register_opt(project_config.inspector_service_option,
                           group='service_available')
         for group, option in _opts:
             config.register_opt_group(conf, group, option)
diff --git a/ironic_tempest_plugin/rules/basic_ops_rule.json b/ironic_tempest_plugin/rules/basic_ops_rule.json
new file mode 100644
index 0000000..f1cfb0b
--- /dev/null
+++ b/ironic_tempest_plugin/rules/basic_ops_rule.json
@@ -0,0 +1,25 @@
+[
+    {
+        "description": "Successful Rule",
+        "conditions": [
+            {"op": "ge", "field": "memory_mb", "value": 256},
+            {"op": "ge", "field": "local_gb", "value": 1}
+        ],
+        "actions": [
+            {"action": "set-attribute", "path": "/extra/rule_success",
+             "value": "yes"}
+        ]
+    },
+    {
+        "description": "Failing Rule",
+        "conditions": [
+            {"op": "lt", "field": "memory_mb", "value": 42},
+            {"op": "eq", "field": "local_gb", "value": 0}
+        ],
+        "actions": [
+            {"action": "set-attribute", "path": "/extra/rule_success",
+             "value": "no"},
+            {"action": "fail", "message": "This rule should not have run"}
+        ]
+    }
+]
diff --git a/ironic_tempest_plugin/services/introspection_client.py b/ironic_tempest_plugin/services/introspection_client.py
new file mode 100644
index 0000000..3b1a75b
--- /dev/null
+++ b/ironic_tempest_plugin/services/introspection_client.py
@@ -0,0 +1,83 @@
+#    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 ironic_tempest_plugin.services.baremetal import base
+from tempest import clients
+from tempest.common import credentials_factory as common_creds
+from tempest import config
+
+
+CONF = config.CONF
+ADMIN_CREDS = common_creds.get_configured_admin_credentials()
+
+
+class Manager(clients.Manager):
+    def __init__(self,
+                 credentials=ADMIN_CREDS,
+                 api_microversions=None):
+        super(Manager, self).__init__(credentials)
+        self.introspection_client = BaremetalIntrospectionClient(
+            self.auth_provider,
+            CONF.baremetal_introspection.catalog_type,
+            CONF.identity.region,
+            endpoint_type=CONF.baremetal_introspection.endpoint_type)
+
+
+class BaremetalIntrospectionClient(base.BaremetalClient):
+    """Base Tempest REST client for Ironic Inspector API v1."""
+    version = '1'
+    uri_prefix = 'v1'
+
+    @base.handle_errors
+    def purge_rules(self):
+        """Purge all existing rules."""
+        return self._delete_request('rules', uuid=None)
+
+    @base.handle_errors
+    def create_rules(self, rules):
+        """Create introspection rules."""
+        if not isinstance(rules, list):
+            rules = [rules]
+        for rule in rules:
+            self._create_request('rules', rule)
+
+    @base.handle_errors
+    def get_status(self, uuid):
+        """Get introspection status for a node."""
+        return self._show_request('introspection', uuid=uuid)
+
+    @base.handle_errors
+    def get_data(self, uuid):
+        """Get introspection data for a node."""
+        return self._show_request('introspection', uuid=uuid,
+                                  uri='/%s/introspection/%s/data' %
+                                      (self.uri_prefix, uuid))
+
+    @base.handle_errors
+    def start_introspection(self, uuid):
+        """Start introspection for a node."""
+        resp, _body = self.post(url=('/%s/introspection/%s' %
+                                     (self.uri_prefix, uuid)),
+                                body=None)
+        self.expected_success(202, resp.status)
+
+        return resp
+
+    @base.handle_errors
+    def abort_introspection(self, uuid):
+        """Abort introspection for a node."""
+        resp, _body = self.post(url=('/%s/introspection/%s/abort' %
+                                     (self.uri_prefix, uuid)),
+                                body=None)
+        self.expected_success(202, resp.status)
+
+        return resp
diff --git a/ironic_tempest_plugin/tests/manager.py b/ironic_tempest_plugin/tests/manager.py
new file mode 100644
index 0000000..e6eb389
--- /dev/null
+++ b/ironic_tempest_plugin/tests/manager.py
@@ -0,0 +1,243 @@
+#    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 json
+import os
+import time
+
+from ironic_tempest_plugin.tests.api.admin.api_microversion_fixture import \
+    APIMicroversionFixture as IronicMicroversionFixture
+from ironic_tempest_plugin.tests.scenario.baremetal_manager import \
+    BaremetalProvisionStates
+from ironic_tempest_plugin.tests.scenario.baremetal_manager import \
+    BaremetalScenarioTest
+import six
+import tempest
+from tempest import config
+from tempest.lib.common.api_version_utils import LATEST_MICROVERSION
+from tempest.lib.common.utils import test_utils
+from tempest.lib import exceptions as lib_exc
+
+from ironic_inspector.test.inspector_tempest_plugin import exceptions
+from ironic_inspector.test.inspector_tempest_plugin.services import \
+    introspection_client
+
+CONF = config.CONF
+
+
+class InspectorScenarioTest(BaremetalScenarioTest):
+    """Provide harness to do Inspector scenario tests."""
+
+    wait_provisioning_state_interval = 15
+
+    credentials = ['primary', 'admin']
+
+    ironic_api_version = LATEST_MICROVERSION
+
+    @classmethod
+    def setup_clients(cls):
+        super(InspectorScenarioTest, cls).setup_clients()
+        inspector_manager = introspection_client.Manager()
+        cls.introspection_client = inspector_manager.introspection_client
+
+    def setUp(self):
+        super(InspectorScenarioTest, self).setUp()
+        # we rely on the 'available' provision_state; using latest
+        # microversion
+        self.useFixture(IronicMicroversionFixture(self.ironic_api_version))
+        self.flavor = self.baremetal_flavor()
+        self.node_ids = {node['uuid'] for node in
+                         self.node_filter(filter=lambda node:
+                                          node['provision_state'] ==
+                                          BaremetalProvisionStates.AVAILABLE)}
+        self.rule_purge()
+
+    def item_filter(self, list_method, show_method,
+                    filter=lambda item: True, items=None):
+        if items is None:
+            items = [show_method(item['uuid']) for item in
+                     list_method()]
+        return [item for item in items if filter(item)]
+
+    def node_list(self):
+        return self.baremetal_client.list_nodes()[1]['nodes']
+
+    def node_port_list(self, node_uuid):
+        return self.baremetal_client.list_node_ports(node_uuid)[1]['ports']
+
+    def node_update(self, uuid, patch):
+        return self.baremetal_client.update_node(uuid, **patch)
+
+    def node_show(self, uuid):
+        return self.baremetal_client.show_node(uuid)[1]
+
+    def node_delete(self, uuid):
+        return self.baremetal_client.delete_node(uuid)
+
+    def node_filter(self, filter=lambda node: True, nodes=None):
+        return self.item_filter(self.node_list, self.node_show,
+                                filter=filter, items=nodes)
+
+    def node_set_power_state(self, uuid, state):
+        self.baremetal_client.set_node_power_state(uuid, state)
+
+    def node_set_provision_state(self, uuid, state):
+        self.baremetal_client.set_node_provision_state(self, uuid, state)
+
+    def hypervisor_stats(self):
+        return (self.os_admin.hypervisor_client.
+                show_hypervisor_statistics())
+
+    def server_show(self, uuid):
+        self.servers_client.show_server(uuid)
+
+    def rule_purge(self):
+        self.introspection_client.purge_rules()
+
+    def rule_import(self, rule_path):
+        with open(rule_path, 'r') as fp:
+            rules = json.load(fp)
+        self.introspection_client.create_rules(rules)
+
+    def rule_import_from_dict(self, rules):
+        self.introspection_client.create_rules(rules)
+
+    def introspection_status(self, uuid):
+        return self.introspection_client.get_status(uuid)[1]
+
+    def introspection_data(self, uuid):
+        return self.introspection_client.get_data(uuid)[1]
+
+    def introspection_start(self, uuid):
+        return self.introspection_client.start_introspection(uuid)
+
+    def introspection_abort(self, uuid):
+        return self.introspection_client.abort_introspection(uuid)
+
+    def baremetal_flavor(self):
+        flavor_id = CONF.compute.flavor_ref
+        flavor = self.flavors_client.show_flavor(flavor_id)['flavor']
+        flavor['properties'] = self.flavors_client.list_flavor_extra_specs(
+            flavor_id)['extra_specs']
+        return flavor
+
+    def get_rule_path(self, rule_file):
+        base_path = os.path.split(
+            os.path.dirname(os.path.abspath(__file__)))[0]
+        base_path = os.path.split(base_path)[0]
+        return os.path.join(base_path, "inspector_tempest_plugin",
+                            "rules", rule_file)
+
+    def boot_instance(self):
+        return super(InspectorScenarioTest, self).boot_instance()
+
+    def terminate_instance(self, instance):
+        return super(InspectorScenarioTest, self).terminate_instance(instance)
+
+    def wait_for_node(self, node_name):
+        def check_node():
+            try:
+                self.node_show(node_name)
+            except lib_exc.NotFound:
+                return False
+            return True
+
+        if not test_utils.call_until_true(
+                check_node,
+                duration=CONF.baremetal_introspection.discovery_timeout,
+                sleep_for=20):
+            msg = ("Timed out waiting for node %s " % node_name)
+            raise lib_exc.TimeoutException(msg)
+
+        inspected_node = self.node_show(self.node_info['name'])
+        self.wait_for_introspection_finished(inspected_node['uuid'])
+
+    # TODO(aarefiev): switch to call_until_true
+    def wait_for_introspection_finished(self, node_ids):
+        """Waits for introspection of baremetal nodes to finish.
+
+        """
+        if isinstance(node_ids, six.text_type):
+            node_ids = [node_ids]
+        start = int(time.time())
+        not_introspected = {node_id for node_id in node_ids}
+
+        while not_introspected:
+            time.sleep(CONF.baremetal_introspection.introspection_sleep)
+            for node_id in node_ids:
+                status = self.introspection_status(node_id)
+                if status['finished']:
+                    if status['error']:
+                        message = ('Node %(node_id)s introspection failed '
+                                   'with %(error)s.' %
+                                   {'node_id': node_id,
+                                    'error': status['error']})
+                        raise exceptions.IntrospectionFailed(message)
+                    not_introspected = not_introspected - {node_id}
+
+            if (int(time.time()) - start >=
+                    CONF.baremetal_introspection.introspection_timeout):
+                message = ('Introspection timed out for nodes: %s' %
+                           not_introspected)
+                raise exceptions.IntrospectionTimeout(message)
+
+    def wait_for_nova_aware_of_bvms(self):
+        start = int(time.time())
+        while True:
+            time.sleep(CONF.baremetal_introspection.hypervisor_update_sleep)
+            stats = self.hypervisor_stats()
+            expected_cpus = self.baremetal_flavor()['vcpus']
+            if int(stats['hypervisor_statistics']['vcpus']) >= expected_cpus:
+                break
+
+            timeout = CONF.baremetal_introspection.hypervisor_update_timeout
+            if (int(time.time()) - start >= timeout):
+                message = (
+                    'Timeout while waiting for nova hypervisor-stats: '
+                    '%(stats)s required time (%(timeout)s s).' %
+                    {'stats': stats,
+                     'timeout': timeout})
+                raise exceptions.HypervisorUpdateTimeout(message)
+
+    def node_cleanup(self, node_id):
+        if (self.node_show(node_id)['provision_state'] ==
+           BaremetalProvisionStates.AVAILABLE):
+            return
+        # in case when introspection failed we need set provision state
+        # to 'manage' to make it possible transit into 'provide' state
+        if self.node_show(node_id)['provision_state'] == 'inspect failed':
+            self.baremetal_client.set_node_provision_state(node_id, 'manage')
+
+        try:
+            self.baremetal_client.set_node_provision_state(node_id, 'provide')
+        except tempest.lib.exceptions.RestClientException:
+            # maybe node already cleaning or available
+            pass
+
+        self.wait_provisioning_state(
+            node_id, [BaremetalProvisionStates.AVAILABLE,
+                      BaremetalProvisionStates.NOSTATE],
+            timeout=CONF.baremetal.unprovision_timeout,
+            interval=self.wait_provisioning_state_interval)
+
+    def introspect_node(self, node_id, remove_props=True):
+        if remove_props:
+            # in case there are properties remove those
+            patch = {('properties/%s' % key): None for key in
+                     self.node_show(node_id)['properties']}
+            # reset any previous rule result
+            patch['extra/rule_success'] = None
+            self.node_update(node_id, patch)
+
+        self.baremetal_client.set_node_provision_state(node_id, 'manage')
+        self.baremetal_client.set_node_provision_state(node_id, 'inspect')
+        self.addCleanup(self.node_cleanup, node_id)
diff --git a/ironic_tempest_plugin/tests/test_basic.py b/ironic_tempest_plugin/tests/test_basic.py
new file mode 100644
index 0000000..a6087e3
--- /dev/null
+++ b/ironic_tempest_plugin/tests/test_basic.py
@@ -0,0 +1,176 @@
+#    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 ironic_tempest_plugin.tests.scenario import baremetal_manager
+from tempest.common import utils
+from tempest.config import CONF
+from tempest.lib import decorators
+
+from ironic_inspector.test.inspector_tempest_plugin.tests import manager
+
+
+class InspectorBasicTest(manager.InspectorScenarioTest):
+
+    def verify_node_introspection_data(self, node):
+        data = self.introspection_data(node['uuid'])
+        self.assertEqual(data['cpu_arch'],
+                         self.flavor['properties']['cpu_arch'])
+        self.assertEqual(int(data['memory_mb']),
+                         int(self.flavor['ram']))
+        self.assertEqual(int(data['cpus']), int(self.flavor['vcpus']))
+
+    def verify_node_flavor(self, node):
+        expected_cpus = self.flavor['vcpus']
+        expected_memory_mb = self.flavor['ram']
+        expected_cpu_arch = self.flavor['properties']['cpu_arch']
+        disk_size = self.flavor['disk']
+        ephemeral_size = self.flavor['OS-FLV-EXT-DATA:ephemeral']
+        expected_local_gb = disk_size + ephemeral_size
+
+        self.assertEqual(expected_cpus,
+                         int(node['properties']['cpus']))
+        self.assertEqual(expected_memory_mb,
+                         int(node['properties']['memory_mb']))
+        self.assertEqual(expected_local_gb,
+                         int(node['properties']['local_gb']))
+        self.assertEqual(expected_cpu_arch,
+                         node['properties']['cpu_arch'])
+
+    def verify_introspection_aborted(self, uuid):
+        status = self.introspection_status(uuid)
+
+        self.assertEqual('Canceled by operator', status['error'])
+        self.assertTrue(status['finished'])
+
+        self.wait_provisioning_state(
+            uuid, 'inspect failed',
+            timeout=CONF.baremetal.active_timeout,
+            interval=self.wait_provisioning_state_interval)
+
+    @decorators.idempotent_id('03bf7990-bee0-4dd7-bf74-b97ad7b52a4b')
+    @utils.services('compute', 'image', 'network')
+    def test_baremetal_introspection(self):
+        """This smoke test case follows this set of operations:
+
+            * Fetches expected properties from baremetal flavor
+            * Removes all properties from nodes
+            * Sets nodes to manageable state
+            * Imports introspection rule basic_ops_rule.json
+            * Inspects nodes
+            * Verifies all properties are inspected
+            * Verifies introspection data
+            * Sets node to available state
+            * Creates a keypair
+            * Boots an instance using the keypair
+            * Deletes the instance
+
+        """
+        # prepare introspection rule
+        rule_path = self.get_rule_path("basic_ops_rule.json")
+        self.rule_import(rule_path)
+        self.addCleanup(self.rule_purge)
+
+        for node_id in self.node_ids:
+            self.introspect_node(node_id)
+
+        # settle down introspection
+        self.wait_for_introspection_finished(self.node_ids)
+        for node_id in self.node_ids:
+            self.wait_provisioning_state(
+                node_id, 'manageable',
+                timeout=CONF.baremetal_introspection.ironic_sync_timeout,
+                interval=self.wait_provisioning_state_interval)
+
+        for node_id in self.node_ids:
+            node = self.node_show(node_id)
+            self.assertEqual('yes', node['extra']['rule_success'])
+            if CONF.service_available.swift:
+                self.verify_node_introspection_data(node)
+            self.verify_node_flavor(node)
+
+        for node_id in self.node_ids:
+            self.baremetal_client.set_node_provision_state(node_id, 'provide')
+
+        for node_id in self.node_ids:
+            self.wait_provisioning_state(
+                node_id, baremetal_manager.BaremetalProvisionStates.AVAILABLE,
+                timeout=CONF.baremetal.active_timeout,
+                interval=self.wait_provisioning_state_interval)
+
+        self.wait_for_nova_aware_of_bvms()
+        self.add_keypair()
+        ins, _node = self.boot_instance()
+        self.terminate_instance(ins)
+
+    @decorators.idempotent_id('70ca3070-184b-4b7d-8892-e977d2bc2870')
+    def test_introspection_abort(self):
+        """This smoke test case follows this very basic set of operations:
+
+            * Start nodes introspection
+            * Wait until nodes power on
+            * Abort introspection
+            * Verifies nodes status and power state
+
+        """
+        # start nodes introspection
+        for node_id in self.node_ids:
+            self.introspect_node(node_id, remove_props=False)
+
+        # wait for nodes power on
+        for node_id in self.node_ids:
+            self.wait_power_state(
+                node_id,
+                baremetal_manager.BaremetalPowerStates.POWER_ON)
+
+        # abort introspection
+        for node_id in self.node_ids:
+            self.introspection_abort(node_id)
+
+        # wait for nodes power off
+        for node_id in self.node_ids:
+            self.wait_power_state(
+                node_id,
+                baremetal_manager.BaremetalPowerStates.POWER_OFF)
+
+        # verify nodes status and provision state
+        for node_id in self.node_ids:
+            self.verify_introspection_aborted(node_id)
+
+
+class InspectorSmokeTest(manager.InspectorScenarioTest):
+
+    @decorators.idempotent_id('a702d1f1-88e4-42ce-88ef-cba2d9e3312e')
+    @decorators.attr(type='smoke')
+    @utils.services('object_storage')
+    def test_baremetal_introspection(self):
+        """This smoke test case follows this very basic set of operations:
+
+            * Fetches expected properties from baremetal flavor
+            * Removes all properties from one node
+            * Sets the node to manageable state
+            * Inspects the node
+            * Sets the node to available state
+
+        """
+        # NOTE(dtantsur): we can't silently skip this test because it runs in
+        # grenade with several other tests, and we won't have any indication
+        # that it was not run.
+        assert self.node_ids, "No available nodes"
+        node_id = next(iter(self.node_ids))
+        self.introspect_node(node_id)
+
+        # settle down introspection
+        self.wait_for_introspection_finished([node_id])
+        self.wait_provisioning_state(
+            node_id, 'manageable',
+            timeout=CONF.baremetal_introspection.ironic_sync_timeout,
+            interval=self.wait_provisioning_state_interval)
diff --git a/ironic_tempest_plugin/tests/test_discovery.py b/ironic_tempest_plugin/tests/test_discovery.py
new file mode 100644
index 0000000..f222810
--- /dev/null
+++ b/ironic_tempest_plugin/tests/test_discovery.py
@@ -0,0 +1,150 @@
+#    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 ironic_tempest_plugin.tests.scenario import baremetal_manager
+from tempest import config
+from tempest.lib import decorators
+from tempest import test  # noqa
+
+from ironic_inspector.test.inspector_tempest_plugin.tests import manager
+
+CONF = config.CONF
+
+ProvisionStates = baremetal_manager.BaremetalProvisionStates
+
+
+class InspectorDiscoveryTest(manager.InspectorScenarioTest):
+    @classmethod
+    def skip_checks(cls):
+        super(InspectorDiscoveryTest, cls).skip_checks()
+        if not CONF.baremetal_introspection.auto_discovery_feature:
+            msg = ("Please, provide a value for node_not_found_hook in "
+                   "processing section of inspector.conf for enable "
+                   "auto-discovery feature.")
+            raise cls.skipException(msg)
+
+    def setUp(self):
+        super(InspectorDiscoveryTest, self).setUp()
+
+        discovered_node = self._get_discovery_node()
+        self.node_info = self._get_node_info(discovered_node)
+
+        rule = self._generate_discovery_rule(self.node_info)
+
+        self.rule_import_from_dict(rule)
+        self.addCleanup(self.rule_purge)
+
+    def _get_node_info(self, node_uuid):
+        node = self.node_show(node_uuid)
+        ports = self.node_port_list(node_uuid)
+        node['port_macs'] = [port['address'] for port in ports]
+        return node
+
+    def _get_discovery_node(self):
+        nodes = self.node_list()
+
+        discovered_node = None
+        for node in nodes:
+            if (node['provision_state'] == ProvisionStates.AVAILABLE or
+                    node['provision_state'] == ProvisionStates.ENROLL or
+                    node['provision_state'] is ProvisionStates.NOSTATE):
+                discovered_node = node['uuid']
+                break
+
+        self.assertIsNotNone(discovered_node)
+        return discovered_node
+
+    def _generate_discovery_rule(self, node):
+        rule = dict()
+        rule["description"] = "Node %s discovery rule" % node['name']
+        rule["actions"] = [
+            {"action": "set-attribute", "path": "/name",
+             "value": "%s" % node['name']},
+            {"action": "set-attribute", "path": "/driver",
+             "value": "%s" % node['driver']},
+        ]
+
+        for key, value in node['driver_info'].items():
+            rule["actions"].append(
+                {"action": "set-attribute", "path": "/driver_info/%s" % key,
+                 "value": "%s" % value})
+        rule["conditions"] = [
+            {"op": "eq", "field": "data://auto_discovered", "value": True}
+        ]
+        return rule
+
+    def verify_node_introspection_data(self, node):
+        data = self.introspection_data(node['uuid'])
+        self.assertEqual(data['cpu_arch'],
+                         self.flavor['properties']['cpu_arch'])
+        self.assertEqual(int(data['memory_mb']),
+                         int(self.flavor['ram']))
+        self.assertEqual(int(data['cpus']), int(self.flavor['vcpus']))
+
+    def verify_node_flavor(self, node):
+        expected_cpus = self.flavor['vcpus']
+        expected_memory_mb = self.flavor['ram']
+        expected_cpu_arch = self.flavor['properties']['cpu_arch']
+        disk_size = self.flavor['disk']
+        ephemeral_size = self.flavor['OS-FLV-EXT-DATA:ephemeral']
+        expected_local_gb = disk_size + ephemeral_size
+
+        self.assertEqual(expected_cpus,
+                         int(node['properties']['cpus']))
+        self.assertEqual(expected_memory_mb,
+                         int(node['properties']['memory_mb']))
+        self.assertEqual(expected_local_gb,
+                         int(node['properties']['local_gb']))
+        self.assertEqual(expected_cpu_arch,
+                         node['properties']['cpu_arch'])
+
+    def verify_node_driver_info(self, node_info, inspected_node):
+        for key in node_info['driver_info']:
+            self.assertEqual(six.text_type(node_info['driver_info'][key]),
+                             inspected_node['driver_info'].get(key))
+
+    @decorators.idempotent_id('dd3abe5e-0d23-488d-bb4e-344cdeff7dcb')
+    def test_bearmetal_auto_discovery(self):
+        """This test case follows this set of operations:
+
+           * Choose appropriate node, based on provision state;
+           * Get node info;
+           * Generate discovery rule;
+           * Delete discovered node from ironic;
+           * Start baremetal vm via virsh;
+           * Wating for node introspection;
+           * Verify introspected node.
+        """
+        # NOTE(aarefiev): workaround for infra, 'tempest' user doesn't
+        # have virsh privileges, so lets power on the node via ironic
+        # and then delete it. Because of node is blacklisted in inspector
+        # we can't just power on it, therefor start introspection is used
+        # to whitelist discovered node first.
+        self.baremetal_client.set_node_provision_state(
+            self.node_info['uuid'], 'manage')
+        self.introspection_start(self.node_info['uuid'])
+        self.wait_power_state(
+            self.node_info['uuid'],
+            baremetal_manager.BaremetalPowerStates.POWER_ON)
+        self.node_delete(self.node_info['uuid'])
+
+        self.wait_for_node(self.node_info['name'])
+
+        inspected_node = self.node_show(self.node_info['name'])
+        self.verify_node_flavor(inspected_node)
+        if CONF.service_available.swift:
+            self.verify_node_introspection_data(inspected_node)
+        self.verify_node_driver_info(self.node_info, inspected_node)
+        self.assertEqual(ProvisionStates.ENROLL,
+                         inspected_node['provision_state'])