Merge "Add 'params' argument to v2 list_backups API"
diff --git a/releasenotes/notes/identity_client-635275d43abbb807.yaml b/releasenotes/notes/identity_client-635275d43abbb807.yaml
new file mode 100644
index 0000000..6f984b7
--- /dev/null
+++ b/releasenotes/notes/identity_client-635275d43abbb807.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Enhances the v3 identity client with the ``check_token_existence``
+    endpoint, allowing users to check the existence of tokens
diff --git a/releasenotes/notes/intermediate-pike-release-2ce492432ff8f012.yaml b/releasenotes/notes/intermediate-pike-release-2ce492432ff8f012.yaml
new file mode 100644
index 0000000..bfebcd9
--- /dev/null
+++ b/releasenotes/notes/intermediate-pike-release-2ce492432ff8f012.yaml
@@ -0,0 +1,4 @@
+---
+prelude: >
+    This is an intermediate release during the Pike development cycle to
+    make new functionality available to plugins and other consumers.
diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst
index d2be814..db01da0 100644
--- a/releasenotes/source/index.rst
+++ b/releasenotes/source/index.rst
@@ -6,6 +6,7 @@
     :maxdepth: 1
 
     unreleased
+    v16.1.0
     v16.0.0
     v15.0.0
     v14.0.0
diff --git a/releasenotes/source/v16.1.0.rst b/releasenotes/source/v16.1.0.rst
new file mode 100644
index 0000000..e24a70f
--- /dev/null
+++ b/releasenotes/source/v16.1.0.rst
@@ -0,0 +1,6 @@
+=====================
+v16.1.0 Release Notes
+=====================
+
+.. release-notes:: 16.1.0 Release Notes
+   :version: 16.1.0
diff --git a/tempest/api/compute/admin/test_live_migration.py b/tempest/api/compute/admin/test_live_migration.py
index 3859e64..705e567 100644
--- a/tempest/api/compute/admin/test_live_migration.py
+++ b/tempest/api/compute/admin/test_live_migration.py
@@ -29,13 +29,13 @@
 LOG = logging.getLogger(__name__)
 
 
-class LiveBlockMigrationTestJSON(base.BaseV2ComputeAdminTest):
+class LiveMigrationTest(base.BaseV2ComputeAdminTest):
     max_microversion = '2.24'
     block_migration = None
 
     @classmethod
     def skip_checks(cls):
-        super(LiveBlockMigrationTestJSON, cls).skip_checks()
+        super(LiveMigrationTest, cls).skip_checks()
 
         if not CONF.compute_feature_enabled.live_migration:
             skip_msg = ("%s skipped as live-migration is "
@@ -47,26 +47,10 @@
 
     @classmethod
     def setup_clients(cls):
-        super(LiveBlockMigrationTestJSON, cls).setup_clients()
+        super(LiveMigrationTest, cls).setup_clients()
         cls.admin_hosts_client = cls.os_admin.hosts_client
         cls.admin_migration_client = cls.os_admin.migrations_client
 
-    @classmethod
-    def _get_compute_hostnames(cls):
-        body = cls.admin_hosts_client.list_hosts()['hosts']
-        return [
-            host_record['host_name']
-            for host_record in body
-            if host_record['service'] == 'compute'
-        ]
-
-    def _get_server_details(self, server_id):
-        body = self.admin_servers_client.show_server(server_id)['server']
-        return body
-
-    def _get_host_for_server(self, server_id):
-        return self._get_server_details(server_id)['OS-EXT-SRV-ATTR:host']
-
     def _migrate_server_to(self, server_id, dest_host, volume_backed=False):
         kwargs = dict()
         block_migration = getattr(self, 'block_migration', None)
@@ -79,11 +63,6 @@
             server_id, host=dest_host, block_migration=block_migration,
             **kwargs)
 
-    def _get_host_other_than(self, host):
-        for target_host in self._get_compute_hostnames():
-            if host != target_host:
-                return target_host
-
     def _live_migrate(self, server_id, target_host, state,
                       volume_backed=False):
         self._migrate_server_to(server_id, target_host, volume_backed)
@@ -97,7 +76,7 @@
             if (live_migration['instance_uuid'] == server_id):
                 msg += "\n%s" % live_migration
         msg += "]"
-        self.assertEqual(target_host, self._get_host_for_server(server_id),
+        self.assertEqual(target_host, self.get_host_for_server(server_id),
                          msg)
 
     def _test_live_migration(self, state='ACTIVE', volume_backed=False):
@@ -114,8 +93,8 @@
         # Live migrate an instance to another host
         server_id = self.create_test_server(wait_until="ACTIVE",
                                             volume_backed=volume_backed)['id']
-        source_host = self._get_host_for_server(server_id)
-        destination_host = self._get_host_other_than(source_host)
+        source_host = self.get_host_for_server(server_id)
+        destination_host = self.get_host_other_than(server_id)
 
         if state == 'PAUSED':
             self.admin_servers_client.pause_server(server_id)
@@ -158,8 +137,7 @@
     def test_iscsi_volume(self):
         server = self.create_test_server(wait_until="ACTIVE")
         server_id = server['id']
-        actual_host = self._get_host_for_server(server_id)
-        target_host = self._get_host_other_than(actual_host)
+        target_host = self.get_host_other_than(server_id)
 
         volume = self.create_volume()
 
@@ -174,11 +152,11 @@
         server = self.admin_servers_client.show_server(server_id)['server']
         volume_id2 = server["os-extended-volumes:volumes_attached"][0]["id"]
 
-        self.assertEqual(target_host, self._get_host_for_server(server_id))
+        self.assertEqual(target_host, self.get_host_for_server(server_id))
         self.assertEqual(volume_id1, volume_id2)
 
 
-class LiveBlockMigrationRemoteConsolesV26TestJson(LiveBlockMigrationTestJSON):
+class LiveMigrationRemoteConsolesV26Test(LiveMigrationTest):
     min_microversion = '2.6'
     max_microversion = 'latest'
 
@@ -201,8 +179,8 @@
         hints = {'different_host': server01_id}
         server02_id = self.create_test_server(scheduler_hints=hints,
                                               wait_until='ACTIVE')['id']
-        host01_id = self._get_host_for_server(server01_id)
-        host02_id = self._get_host_for_server(server02_id)
+        host01_id = self.get_host_for_server(server01_id)
+        host02_id = self.get_host_for_server(server02_id)
         self.assertNotEqual(host01_id, host02_id)
 
         # At this step we have 2 instances on different hosts, both with
@@ -216,7 +194,7 @@
         self._migrate_server_to(server01_id, host02_id)
         waiters.wait_for_server_status(self.servers_client,
                                        server01_id, 'ACTIVE')
-        self.assertEqual(host02_id, self._get_host_for_server(server01_id))
+        self.assertEqual(host02_id, self.get_host_for_server(server01_id))
         self._verify_console_interaction(server01_id)
         # At this point, both instances have a valid serial console
         # connection, which means the ports got updated.
@@ -252,7 +230,7 @@
         self.assertIn(data, console_output)
 
 
-class LiveAutoBlockMigrationV225TestJSON(LiveBlockMigrationTestJSON):
+class LiveAutoBlockMigrationV225Test(LiveMigrationTest):
     min_microversion = '2.25'
     max_microversion = 'latest'
     block_migration = 'auto'
diff --git a/tempest/api/compute/admin/test_live_block_migration_negative.py b/tempest/api/compute/admin/test_live_migration_negative.py
similarity index 72%
rename from tempest/api/compute/admin/test_live_block_migration_negative.py
rename to tempest/api/compute/admin/test_live_migration_negative.py
index ab63154..deabbc2 100644
--- a/tempest/api/compute/admin/test_live_block_migration_negative.py
+++ b/tempest/api/compute/admin/test_live_migration_negative.py
@@ -23,10 +23,10 @@
 CONF = config.CONF
 
 
-class LiveBlockMigrationNegativeTestJSON(base.BaseV2ComputeAdminTest):
+class LiveMigrationNegativeTest(base.BaseV2ComputeAdminTest):
     @classmethod
     def skip_checks(cls):
-        super(LiveBlockMigrationNegativeTestJSON, cls).skip_checks()
+        super(LiveMigrationNegativeTest, cls).skip_checks()
         if not CONF.compute_feature_enabled.live_migration:
             raise cls.skipException("Live migration is not enabled")
 
@@ -47,3 +47,17 @@
                           server['id'], target_host)
         waiters.wait_for_server_status(self.servers_client, server['id'],
                                        'ACTIVE')
+
+    @decorators.attr(type=['negative'])
+    @decorators.idempotent_id('6e2f94f5-2ee8-4830-bef5-5bc95bb0795b')
+    def test_live_block_migration_suspended(self):
+        server = self.create_test_server(wait_until="ACTIVE")
+
+        self.admin_servers_client.suspend_server(server['id'])
+        waiters.wait_for_server_status(self.servers_client,
+                                       server['id'], 'SUSPENDED')
+
+        destination_host = self.get_host_other_than(server['id'])
+
+        self.assertRaises(lib_exc.Conflict, self._migrate_server_to,
+                          server['id'], destination_host)
diff --git a/tempest/api/compute/base.py b/tempest/api/compute/base.py
index ddb035d..feabe35 100644
--- a/tempest/api/compute/base.py
+++ b/tempest/api/compute/base.py
@@ -487,3 +487,21 @@
         self.addCleanup(client.wait_for_resource_deletion, flavor['id'])
         self.addCleanup(client.delete_flavor, flavor['id'])
         return flavor
+
+    def get_host_for_server(self, server_id):
+        server_details = self.admin_servers_client.show_server(server_id)
+        return server_details['server']['OS-EXT-SRV-ATTR:host']
+
+    def get_host_other_than(self, server_id):
+        source_host = self.get_host_for_server(server_id)
+
+        list_hosts_resp = self.os_admin.hosts_client.list_hosts()['hosts']
+        hosts = [
+            host_record['host_name']
+            for host_record in list_hosts_resp
+            if host_record['service'] == 'compute'
+        ]
+
+        for target_host in hosts:
+            if source_host != target_host:
+                return target_host
diff --git a/tempest/api/identity/admin/v3/test_tokens.py b/tempest/api/identity/admin/v3/test_tokens.py
index 1acc67d..5c3cd26 100644
--- a/tempest/api/identity/admin/v3/test_tokens.py
+++ b/tempest/api/identity/admin/v3/test_tokens.py
@@ -39,6 +39,7 @@
         resp = self.token.auth(user_id=user['id'],
                                password=u_password).response
         subject_token = resp['x-subject-token']
+        self.client.check_token_existence(subject_token)
         # Perform GET Token
         token_details = self.client.show_token(subject_token)['token']
         self.assertEqual(resp['x-subject-token'], subject_token)
@@ -46,7 +47,7 @@
         self.assertEqual(token_details['user']['name'], u_name)
         # Perform Delete Token
         self.client.delete_token(subject_token)
-        self.assertRaises(lib_exc.NotFound, self.client.show_token,
+        self.assertRaises(lib_exc.NotFound, self.client.check_token_existence,
                           subject_token)
 
     @decorators.idempotent_id('565fa210-1da1-4563-999b-f7b5b67cf112')
diff --git a/tempest/lib/services/identity/v3/identity_client.py b/tempest/lib/services/identity/v3/identity_client.py
index 755c14b..2512a3e 100644
--- a/tempest/lib/services/identity/v3/identity_client.py
+++ b/tempest/lib/services/identity/v3/identity_client.py
@@ -44,6 +44,13 @@
         self.expected_success(204, resp.status)
         return rest_client.ResponseBody(resp, body)
 
+    def check_token_existence(self, resp_token):
+        """Validates a token."""
+        headers = {'X-Subject-Token': resp_token}
+        resp, body = self.head("auth/tokens", headers=headers)
+        self.expected_success(200, resp.status)
+        return rest_client.ResponseBody(resp, body)
+
     def list_auth_projects(self):
         """Get available project scopes."""
         resp, body = self.get("auth/projects")
diff --git a/tempest/test.py b/tempest/test.py
index a81b5d7..fc846ff 100644
--- a/tempest/test.py
+++ b/tempest/test.py
@@ -63,7 +63,14 @@
         'compute': CONF.service_available.nova,
         'image': CONF.service_available.glance,
         'volume': CONF.service_available.cinder,
+        # NOTE(masayukig): We have two network services which are neutron and
+        # nova-network. And we have no way to know whether nova-network is
+        # available or not. After the pending removal of nova-network from
+        # nova, we can treat the network/neutron case in the same manner as
+        # the other services.
         'network': True,
+        # NOTE(masayukig): Tempest tests always require the identity service.
+        # So we should set this True here.
         'identity': True,
         'object_storage': CONF.service_available.swift,
     }
diff --git a/tempest/tests/lib/services/identity/v3/test_identity_client.py b/tempest/tests/lib/services/identity/v3/test_identity_client.py
index e435fe2..6572947 100644
--- a/tempest/tests/lib/services/identity/v3/test_identity_client.py
+++ b/tempest/tests/lib/services/identity/v3/test_identity_client.py
@@ -109,6 +109,14 @@
             resp_token="cbc36478b0bd8e67e89",
             status=204)
 
+    def test_check_token_existence(self):
+        self.check_service_client_function(
+            self.client.check_token_existence,
+            'tempest.lib.common.rest_client.RestClient.head',
+            {},
+            resp_token="cbc36478b0bd8e67e89",
+            status=200)
+
     def test_list_auth_projects_with_str_body(self):
         self._test_list_auth_projects()
 
diff --git a/tempest/tests/lib/services/volume/v2/test_transfers_client.py b/tempest/tests/lib/services/volume/v2/test_transfers_client.py
index 0c59bf2..84f4992 100644
--- a/tempest/tests/lib/services/volume/v2/test_transfers_client.py
+++ b/tempest/tests/lib/services/volume/v2/test_transfers_client.py
@@ -13,6 +13,11 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import copy
+
+import mock
+from oslo_serialization import jsonutils as json
+
 from tempest.lib.services.volume.v2 import transfers_client
 from tempest.tests.lib import fake_auth_provider
 from tempest.tests.lib.services import base
@@ -20,11 +25,14 @@
 
 class TestTransfersClient(base.BaseServiceTest):
 
-    FAKE_LIST_VOLUME_TRANSFERS_WITH_DETAIL = {
-        "transfers": [{
-            "created_at": "2017-04-18T09:10:03.000000",
+    FAKE_VOLUME_TRANSFER_ID = "0e89cdd1-6249-421b-96d8-25fac0623d42"
+
+    FAKE_VOLUME_TRANSFER_INFO = {
+        "transfer": {
+            "id": FAKE_VOLUME_TRANSFER_ID,
+            "name": "fake-volume-transfer",
             "volume_id": "47bf04ef-1ea5-4c5f-a375-430a086d6747",
-            "id": "0e89cdd1-6249-421b-96d8-25fac0623d42",
+            "created_at": "2017-04-18T09:10:03.000000",
             "links": [
                 {
                     "href": "fake-url-1",
@@ -34,9 +42,8 @@
                     "href": "fake-url-2",
                     "rel": "bookmark"
                 }
-            ],
-            "name": "fake-volume-transfer"
-        }]
+            ]
+        }
     }
 
     def setUp(self):
@@ -46,16 +53,106 @@
                                                        'volume',
                                                        'regionOne')
 
-    def _test_list_volume_transfers_with_detail(self, bytes_body=False):
+    def _test_create_volume_transfer(self, bytes_body=False):
+        resp_body = copy.deepcopy(self.FAKE_VOLUME_TRANSFER_INFO)
+        resp_body['transfer'].update({"auth_key": "fake-auth-key"})
+        kwargs = {"name": "fake-volume-transfer",
+                  "volume_id": "47bf04ef-1ea5-4c5f-a375-430a086d6747"}
+        payload = json.dumps({"transfer": kwargs}, sort_keys=True)
+        json_dumps = json.dumps
+
+        # NOTE: Use sort_keys for json.dumps so that the expected and actual
+        # payloads are guaranteed to be identical for mock_args assert check.
+        with mock.patch.object(transfers_client.json, 'dumps') as mock_dumps:
+            mock_dumps.side_effect = lambda d: json_dumps(d, sort_keys=True)
+
+            self.check_service_client_function(
+                self.client.create_volume_transfer,
+                'tempest.lib.common.rest_client.RestClient.post',
+                resp_body,
+                to_utf=bytes_body,
+                status=202,
+                mock_args=['os-volume-transfer', payload],
+                **kwargs)
+
+    def _test_accept_volume_transfer(self, bytes_body=False):
+        resp_body = copy.deepcopy(self.FAKE_VOLUME_TRANSFER_INFO)
+        resp_body['transfer'].pop('created_at')
+        kwargs = {"auth_key": "fake-auth-key"}
+        payload = json.dumps({"accept": kwargs}, sort_keys=True)
+        json_dumps = json.dumps
+
+        # NOTE: Use sort_keys for json.dumps so that the expected and actual
+        # payloads are guaranteed to be identical for mock_args assert check.
+        with mock.patch.object(transfers_client.json, 'dumps') as mock_dumps:
+            mock_dumps.side_effect = lambda d: json_dumps(d, sort_keys=True)
+
+            self.check_service_client_function(
+                self.client.accept_volume_transfer,
+                'tempest.lib.common.rest_client.RestClient.post',
+                resp_body,
+                to_utf=bytes_body,
+                status=202,
+                mock_args=['os-volume-transfer/%s/accept' %
+                           self.FAKE_VOLUME_TRANSFER_ID, payload],
+                transfer_id=self.FAKE_VOLUME_TRANSFER_ID,
+                **kwargs)
+
+    def _test_show_volume_transfer(self, bytes_body=False):
+        resp_body = self.FAKE_VOLUME_TRANSFER_INFO
+        self.check_service_client_function(
+            self.client.show_volume_transfer,
+            'tempest.lib.common.rest_client.RestClient.get',
+            resp_body,
+            to_utf=bytes_body,
+            transfer_id="0e89cdd1-6249-421b-96d8-25fac0623d42")
+
+    def _test_list_volume_transfers(self, detail=False, bytes_body=False):
+        resp_body = copy.deepcopy(self.FAKE_VOLUME_TRANSFER_INFO)
+        if not detail:
+            resp_body['transfer'].pop('created_at')
+        resp_body = {"transfers": [resp_body['transfer']]}
         self.check_service_client_function(
             self.client.list_volume_transfers,
             'tempest.lib.common.rest_client.RestClient.get',
-            self.FAKE_LIST_VOLUME_TRANSFERS_WITH_DETAIL,
-            bytes_body,
-            detail=True)
+            resp_body,
+            to_utf=bytes_body,
+            detail=detail)
+
+    def test_create_volume_transfer_with_str_body(self):
+        self._test_create_volume_transfer()
+
+    def test_create_volume_transfer_with_bytes_body(self):
+        self._test_create_volume_transfer(bytes_body=True)
+
+    def test_accept_volume_transfer_with_str_body(self):
+        self._test_accept_volume_transfer()
+
+    def test_accept_volume_transfer_with_bytes_body(self):
+        self._test_accept_volume_transfer(bytes_body=True)
+
+    def test_show_volume_transfer_with_str_body(self):
+        self._test_show_volume_transfer()
+
+    def test_show_volume_transfer_with_bytes_body(self):
+        self._test_show_volume_transfer(bytes_body=True)
+
+    def test_list_volume_transfers_with_str_body(self):
+        self._test_list_volume_transfers()
+
+    def test_list_volume_transfers_with_bytes_body(self):
+        self._test_list_volume_transfers(bytes_body=True)
 
     def test_list_volume_transfers_with_detail_with_str_body(self):
-        self._test_list_volume_transfers_with_detail()
+        self._test_list_volume_transfers(detail=True)
 
     def test_list_volume_transfers_with_detail_with_bytes_body(self):
-        self._test_list_volume_transfers_with_detail(bytes_body=True)
+        self._test_list_volume_transfers(detail=True, bytes_body=True)
+
+    def test_delete_volume_transfer(self):
+        self.check_service_client_function(
+            self.client.delete_volume_transfer,
+            'tempest.lib.common.rest_client.RestClient.delete',
+            {},
+            status=202,
+            transfer_id="0e89cdd1-6249-421b-96d8-25fac0623d42")
diff --git a/tools/tempest-plugin-sanity.sh b/tools/tempest-plugin-sanity.sh
new file mode 100644
index 0000000..a4f706e
--- /dev/null
+++ b/tools/tempest-plugin-sanity.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+
+# Copyright 2017 Red Hat, Inc.
+# 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.
+
+# This script is intended to check the sanity of tempest plugins against
+# tempest master.
+# What it does:
+# * Creates the virtualenv
+# * Install tempest
+# * Retrive the project lists having tempest plugin if project name is
+#   given.
+# * For each project in a list, It does:
+#   * Clone the Project
+#   * Install the Project and also installs dependencies from
+#     test-requirements.txt.
+#   * Create Tempest workspace
+#   * List tempest plugins
+#   * List tempest plugins tests
+#   * Uninstall the project and its dependencies
+#   * Again Install tempest
+#   * Again repeat the step from cloning project
+#
+# If one of the step fails, The script will exit with failure.
+
+if [ "$1" == "-h" ]; then
+    echo -e "This script performs the sanity of tempest plugins to find
+configuration and dependency issues with the tempest.\n
+Usage: sh ./tools/tempest-plugin-sanity.sh [Run sanity on tempest plugins]"
+    exit 0
+fi
+
+set -ex
+
+# retrieve a list of projects having tempest plugins
+PROJECT_LIST="$(python tools/generate-tempest-plugins-list.py)"
+# List of projects having tempest plugin stale or unmaintained from long time
+BLACKLIST="trio2o"
+
+# Function to clone project using zuul-cloner or from git
+function clone_project() {
+    if [ -e /usr/zuul-env/bin/zuul-cloner ]; then
+        /usr/zuul-env/bin/zuul-cloner --cache-dir /opt/git \
+        git://git.openstack.org \
+        openstack/"$1"
+
+    elif [ -e /usr/bin/git ]; then
+        /usr/bin/git clone git://git.openstack.org/openstack/"$1" \
+        openstack/"$1"
+
+    fi
+}
+
+# Create virtualenv to perform sanity operation
+SANITY_DIR=$(pwd)
+virtualenv "$SANITY_DIR"/.venv
+export TVENV="$SANITY_DIR/tools/with_venv.sh"
+cd "$SANITY_DIR"
+
+# Install tempest in a venv
+"$TVENV" pip install .
+
+# Function to install project
+function install_project() {
+    "$TVENV" pip install "$SANITY_DIR"/openstack/"$1"
+    # Check for test-requirements.txt file in a project then install it.
+    if [ -e "$SANITY_DIR"/openstack/"$1"/test-requirements.txt ]; then
+        "$TVENV" pip install -r "$SANITY_DIR"/openstack/"$1"/test-requirements.txt
+    fi
+}
+
+# Function to perform sanity checking on Tempest plugin
+function tempest_sanity() {
+    "$TVENV" tempest init "$SANITY_DIR"/tempest_sanity
+    cd "$SANITY_DIR"/tempest_sanity
+    "$TVENV" tempest list-plugins
+    "$TVENV" tempest run -l
+    # Delete tempest workspace
+    "$TVENV" tempest workspace remove --name tempest_sanity --rmdir
+    cd "$SANITY_DIR"
+}
+
+# Function to uninstall project
+function uninstall_project() {
+    "$TVENV" pip uninstall -y "$SANITY_DIR"/openstack/"$1"
+    # Check for *requirements.txt file in a project then uninstall it.
+    if [ -e "$SANITY_DIR"/openstack/"$1"/*requirements.txt ]; then
+        "$TVENV" pip uninstall -y -r "$SANITY_DIR"/openstack/"$1"/*requirements.txt
+    fi
+    # Remove the project directory after sanity run
+    rm -fr "$SANITY_DIR"/openstack/"$1"
+}
+
+# Function to run sanity check on each project
+function plugin_sanity_check() {
+        clone_project "$1"  &&  install_project "$1"  &&  tempest_sanity "$1" \
+        &&  uninstall_project "$1"  &&  "$TVENV" pip install .
+}
+
+# Log status
+passed_plugin=''
+failed_plugin=''
+# Perform sanity on all tempest plugin projects
+for project in $PROJECT_LIST; do
+    # Remove blacklisted tempest plugins
+    if ! [[ `echo $BLACKLIST | grep -c $project ` -gt 0 ]]; then
+        plugin_sanity_check $project && passed_plugin+=", $project" || \
+        failed_plugin+=", $project"
+    fi
+done
diff --git a/tox.ini b/tox.ini
index 892f834..2120818 100644
--- a/tox.ini
+++ b/tox.ini
@@ -185,3 +185,9 @@
 # separately, outside of the requirements files.
 deps = bindep
 commands = bindep test
+
+[testenv:plugin-sanity-check]
+# perform tempest plugin sanity
+whitelist_externals = bash
+commands =
+  bash tools/tempest-plugin-sanity.sh