Merge "Use stable constraint in tox to release new tag for Ussuri"
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index a89ad94..2300763 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -1,17 +1,19 @@
-If you would like to contribute to the development of OpenStack, you must
-follow the steps in this page:
+The source repository for this project can be found at:
 
-   https://docs.openstack.org/infra/manual/developers.html
+   https://opendev.org/openstack/tempest
 
-If you already have a good understanding of how the system works and your
-OpenStack accounts are set up, you can skip to the development workflow
-section of this documentation to learn how changes to OpenStack should be
-submitted for review via the Gerrit tool:
+Pull requests submitted through GitHub are not monitored.
 
-   https://docs.openstack.org/infra/manual/developers.html#development-workflow
+To start contributing to OpenStack, follow the steps in the contribution guide
+to set up and use Gerrit:
 
-Pull requests submitted through GitHub will be ignored.
+   https://docs.openstack.org/contributors/code-and-documentation/quick-start.html
 
-Bugs should be filed on Launchpad, not GitHub:
+Bugs should be filed on Launchpad:
 
    https://bugs.launchpad.net/tempest
+
+For more specific information about contributing to this repository, see the
+Tempest contributor guide:
+
+   https://docs.openstack.org/tempest/latest/contributor/contributing.html
diff --git a/doc/source/contributor/contributing.rst b/doc/source/contributor/contributing.rst
new file mode 100644
index 0000000..38673a0
--- /dev/null
+++ b/doc/source/contributor/contributing.rst
@@ -0,0 +1,59 @@
+============================
+So You Want to Contribute...
+============================
+
+For general information on contributing to OpenStack, please check out the
+`contributor guide <https://docs.openstack.org/contributors/>`_ to get started.
+It covers all the basics that are common to all OpenStack projects: the accounts
+you need, the basics of interacting with our Gerrit review system, how we
+communicate as a community, etc.
+
+Below will cover the more project specific information you need to get started
+with Tempest.
+
+Communication
+~~~~~~~~~~~~~
+* IRC channel #openstack-qa at FreeNode
+* Mailing list (prefix subjects with ``[qa]`` for faster responses)
+  http://lists.openstack.org/cgi-bin/mailman/listinfo/openstack-discuss
+
+Contacting the Core Team
+~~~~~~~~~~~~~~~~~~~~~~~~
+Please refer the `Tempest Core Team
+<https://review.opendev.org/#/admin/groups/42,members>`_ contacts.
+
+New Feature Planning
+~~~~~~~~~~~~~~~~~~~~
+If you want to propose a new feature please read `Feature Proposal Process`_
+Tempest features are tracked on `Launchpad BP <https://blueprints.launchpad.net/tempest>`_.
+
+Task Tracking
+~~~~~~~~~~~~~
+We track our tasks in `Launchpad <https://bugs.launchpad.net/tempest>`_.
+
+If you're looking for some smaller, easier work item to pick up and get started
+on, search for the 'low-hanging-fruit' tag.
+
+Reporting a Bug
+~~~~~~~~~~~~~~~
+You found an issue and want to make sure we are aware of it? You can do so on
+`Launchpad <https://bugs.launchpad.net/tempest/+filebug>`__.
+More info about Launchpad usage can be found on `OpenStack docs page
+<https://docs.openstack.org/contributors/common/task-tracking.html#launchpad>`_
+
+Getting Your Patch Merged
+~~~~~~~~~~~~~~~~~~~~~~~~~
+All changes proposed to the Tempest requires two ``Code-Review +2`` votes from
+Tempest core reviewers before one of the core reviewers can approve patch by
+giving ``Workflow +1`` vote. More detailed guidelines for reviewers are available
+at :doc:`../REVIEWING`.
+
+Project Team Lead Duties
+~~~~~~~~~~~~~~~~~~~~~~~~
+All common PTL duties are enumerated in the `PTL guide
+<https://docs.openstack.org/project-team-guide/ptl.html>`_.
+
+Release Process fir QA is documented in `QA Release Process
+<https://wiki.openstack.org/wiki/QA#Releases_Process>`_.
+
+.. _Feature Proposal Process: https://wiki.openstack.org/wiki/QA#Feature_Proposal_.26_Design_discussions
diff --git a/doc/source/index.rst b/doc/source/index.rst
index ab994d1..93fcc3a 100644
--- a/doc/source/index.rst
+++ b/doc/source/index.rst
@@ -56,6 +56,16 @@
 
    supported_version
 
+For Contributors
+================
+
+* If you are a new contributor to Tempest please refer: :doc:`contributor/contributing`
+
+.. toctree::
+   :hidden:
+
+   contributor/contributing
+
 Developers Guide
 ================
 
@@ -80,6 +90,14 @@
    plugin
    plugin-registry
 
+Tempest & Plugins Compatible Version Policy
+-------------------------------------------
+
+.. toctree::
+   :maxdepth: 2
+
+   tempest_and_plugins_compatible_version_policy
+
 Library
 -------
 
@@ -104,4 +122,4 @@
   * :ref:`Tempest document search <search>`: Search the contents of this document.
 
 * `OpenStack wide search <https://docs.openstack.org>`_: Search the wider
-  set of OpenStack documentation, including forums.
\ No newline at end of file
+  set of OpenStack documentation, including forums.
diff --git a/doc/source/microversion_testing.rst b/doc/source/microversion_testing.rst
index b4f06e3..50fd4f2 100644
--- a/doc/source/microversion_testing.rst
+++ b/doc/source/microversion_testing.rst
@@ -414,6 +414,10 @@
 
   .. _2.71: https://docs.openstack.org/nova/latest/reference/api-microversion-history.html#id64
 
+  * `2.73`_
+
+  .. _2.73: https://docs.openstack.org/nova/latest/reference/api-microversion-history.html#id66
+
 * Volume
 
   * `3.3`_
diff --git a/doc/source/tempest_and_plugins_compatible_version_policy.rst b/doc/source/tempest_and_plugins_compatible_version_policy.rst
new file mode 100644
index 0000000..942b1bd
--- /dev/null
+++ b/doc/source/tempest_and_plugins_compatible_version_policy.rst
@@ -0,0 +1,54 @@
+Tempest and Plugins compatible version policy
+=============================================
+
+Tempest and its plugins are responsible for the integrated
+testing of OpenStack. These tools have two use cases:
+
+#. Testing upstream code at gate
+#. Testing Production Cloud
+
+Upstream code is tested by the master version of branchless Tempest & plugins
+for all supported stable branches in `Maintained phase`_.
+
+Production Cloud can be tested by using the compatible version or using
+master version. It depends on the testing strategy of cloud. To provide
+the compatible version of Tempest and its Plugins per OpenStack release,
+we started the coordinated release of all plugins and Tempest per OpenStack
+release.
+These versions are the first set of versions from Tempest and its Plugins to
+officially start the support of a particular OpenStack release. For example:
+OpenStack Train release first compatible versions `Tempest plugins version`_.
+
+Because of branchless nature of Tempest and its plugins, first version
+released during OpenStack release is not the last version to support that
+OpenStack release. This means the next (or master) versions can also be used
+for upstream testing as well as in production testing.
+
+Since the `Extended Maintenance policy`_ for stable branch, Tempest
+started releasing the ``end of support`` version once stable release
+is moved to EM state, which used to happen on EOL of stable release. This is
+the last compatible version of Tempest for the OpenStack release moved to EM.
+
+Because of branchless nature as explained above, we have a range of versions
+which can be considered a compatible version for particular OpenStack release.
+How we should release those versions is mentioned in the below table.
+
+ +-----------------------------+-----------------+------------------------------------+
+ | First compatible version -> | OpenStack 'XYZ' | <- Last compatible version         |
+ +=============================+=================+====================================+
+ |This is the latest version   |                 |This is the version released        |
+ |released when OpenStack      |                 |when OpenStack 'XYZ' is moved to    |
+ |'XYZ' is released.           |                 |EM state. Hash used for this should |
+ |Example:                     |                 |be the hash from master at the time |
+ |`Tempest plugins version`_   |                 |of branch is EM not the one used for|
+ |                             |                 |First compatible version            |
+ +-----------------------------+-----------------+------------------------------------+
+
+Tempest & the Plugins should follow the above mentioned policy for the
+``First compatible version`` and the ``Last compatible version.``
+so that we provide the right set of compatible versions to Upstream as well as to
+Production Cloud testing.
+
+.. _Maintained phase: https://docs.openstack.org/project-team-guide/stable-branches.html#maintained
+.. _Extended Maintenance policy: https://governance.openstack.org/tc/resolutions/20180301-stable-branch-eol.html
+.. _Tempest plugins version: https://releases.openstack.org/train/#tempest-plugins
diff --git a/releasenotes/notes/add-worker-file-option-d949121a61156968.yaml b/releasenotes/notes/add-worker-file-option-d949121a61156968.yaml
new file mode 100644
index 0000000..6b10937
--- /dev/null
+++ b/releasenotes/notes/add-worker-file-option-d949121a61156968.yaml
@@ -0,0 +1,10 @@
+---
+features:
+  - |
+    Add the option --worker-file in ``tempest run`` command. This is to give
+    tempest more granularity to manually configure how the different sets of
+    tests can be grouped to run with the different worker. You can configure
+    tests regex to run under workers. You can also mix manual scheduling with
+    standard one by mentioning concurrency.
+    For example, the user can setup tempest to run with different concurrences,
+    to be used with different regexps.
diff --git a/releasenotes/notes/tempest-ussuri-release-72b5770a3b97678f.yaml b/releasenotes/notes/tempest-ussuri-release-72b5770a3b97678f.yaml
new file mode 100644
index 0000000..37e56bb
--- /dev/null
+++ b/releasenotes/notes/tempest-ussuri-release-72b5770a3b97678f.yaml
@@ -0,0 +1,16 @@
+---
+prelude: >
+    This release is to tag the Tempest for OpenStack Ussuri release.
+    This release marks the start of Ussuri release support in Tempest.
+    After this release, Tempest will support below OpenStack Releases:
+
+    * Ussuri
+    * Train
+    * Stein
+
+    Current development of Tempest is for OpenStack Victoria development
+    cycle. Every Tempest commit is also tested against master during
+    the Victoria cycle. However, this does not necessarily mean that using
+    Tempest as of this tag will work against a Ussuri (or future release)
+    cloud.
+    To be on safe side, use this tag to test the OpenStack Ussuri release.
diff --git a/tempest/api/compute/admin/test_flavors.py b/tempest/api/compute/admin/test_flavors.py
index 1483c2e..f42f53a 100644
--- a/tempest/api/compute/admin/test_flavors.py
+++ b/tempest/api/compute/admin/test_flavors.py
@@ -46,6 +46,7 @@
 
     @decorators.idempotent_id('8b4330e1-12c4-4554-9390-e6639971f086')
     def test_create_flavor_with_int_id(self):
+        """Test creating flavor with id of type integer"""
         flavor_id = data_utils.rand_int_id(start=1000)
         new_flavor_id = self.create_flavor(ram=self.ram,
                                            vcpus=self.vcpus,
@@ -55,6 +56,7 @@
 
     @decorators.idempotent_id('94c9bb4e-2c2a-4f3c-bb1f-5f0daf918e6d')
     def test_create_flavor_with_uuid_id(self):
+        """Test creating flavor with id of type uuid"""
         flavor_id = data_utils.rand_uuid()
         new_flavor_id = self.create_flavor(ram=self.ram,
                                            vcpus=self.vcpus,
@@ -64,8 +66,11 @@
 
     @decorators.idempotent_id('f83fe669-6758-448a-a85e-32d351f36fe0')
     def test_create_flavor_with_none_id(self):
-        # If nova receives a request with None as flavor_id,
-        # nova generates flavor_id of uuid.
+        """Test creating flavor without id specified
+
+        If nova receives a request with None as flavor_id,
+        nova generates flavor_id of uuid.
+        """
         flavor_id = None
         new_flavor_id = self.create_flavor(ram=self.ram,
                                            vcpus=self.vcpus,
@@ -75,8 +80,10 @@
 
     @decorators.idempotent_id('8261d7b0-be58-43ec-a2e5-300573c3f6c5')
     def test_create_flavor_verify_entry_in_list_details(self):
-        # Create a flavor and ensure it's details are listed
-        # This operation requires the user to have 'admin' role
+        """Create a flavor and ensure its details are listed
+
+        This operation requires the user to have 'admin' role
+        """
         flavor_name = data_utils.rand_name(self.flavor_name_prefix)
 
         # Create the flavor
@@ -94,9 +101,10 @@
 
     @decorators.idempotent_id('63dc64e6-2e79-4fdf-868f-85500d308d66')
     def test_create_list_flavor_without_extra_data(self):
-        # Create a flavor and ensure it is listed
-        # This operation requires the user to have 'admin' role
+        """Create a flavor and ensure it is listed
 
+        This operation requires the user to have 'admin' role
+        """
         def verify_flavor_response_extension(flavor):
             # check some extensions for the flavor create/show/detail response
             self.assertEqual(flavor['swap'], '')
@@ -134,10 +142,12 @@
 
     @decorators.idempotent_id('be6cc18c-7c5d-48c0-ac16-17eaf03c54eb')
     def test_list_non_public_flavor(self):
-        # Create a flavor with os-flavor-access:is_public false.
-        # The flavor should not be present in list_details as the
-        # tenant is not automatically added access list.
-        # This operation requires the user to have 'admin' role
+        """Create a flavor with os-flavor-access:is_public false.
+
+        The flavor should not be present in list_details as the
+        tenant is not automatically added access list.
+        This operation requires the user to have 'admin' role
+        """
         flavor_name = data_utils.rand_name(self.flavor_name_prefix)
 
         # Create the flavor
@@ -156,7 +166,7 @@
 
     @decorators.idempotent_id('bcc418ef-799b-47cc-baa1-ce01368b8987')
     def test_create_server_with_non_public_flavor(self):
-        # Create a flavor with os-flavor-access:is_public false
+        """Create a flavor with os-flavor-access:is_public false"""
         flavor = self.create_flavor(ram=self.ram, vcpus=self.vcpus,
                                     disk=self.disk,
                                     is_public="False")
@@ -169,8 +179,10 @@
 
     @decorators.idempotent_id('b345b196-bfbd-4231-8ac1-6d7fe15ff3a3')
     def test_list_public_flavor_with_other_user(self):
-        # Create a Flavor with public access.
-        # Try to List/Get flavor with another user
+        """Create a Flavor with public access.
+
+        Try to List/Get flavor with another user
+        """
         flavor_name = data_utils.rand_name(self.flavor_name_prefix)
 
         # Create the flavor
@@ -184,6 +196,7 @@
 
     @decorators.idempotent_id('fb9cbde6-3a0e-41f2-a983-bdb0a823c44e')
     def test_is_public_string_variations(self):
+        """Test creating public and non public flavors"""
         flavor_name_not_public = data_utils.rand_name(self.flavor_name_prefix)
         flavor_name_public = data_utils.rand_name(self.flavor_name_prefix)
 
@@ -215,6 +228,7 @@
 
     @decorators.idempotent_id('3b541a2e-2ac2-4b42-8b8d-ba6e22fcd4da')
     def test_create_flavor_using_string_ram(self):
+        """Test creating flavor with ram of type string"""
         new_flavor_id = data_utils.rand_int_id(start=1000)
 
         ram = "1024"
diff --git a/tempest/api/compute/admin/test_flavors_extra_specs.py b/tempest/api/compute/admin/test_flavors_extra_specs.py
index 789965e..4c531b3 100644
--- a/tempest/api/compute/admin/test_flavors_extra_specs.py
+++ b/tempest/api/compute/admin/test_flavors_extra_specs.py
@@ -61,8 +61,11 @@
 
     @decorators.idempotent_id('0b2f9d4b-1ca2-4b99-bb40-165d4bb94208')
     def test_flavor_set_get_update_show_unset_keys(self):
-        # Test to SET, GET, UPDATE, SHOW, UNSET flavor extra
-        # spec as a user with admin privileges.
+        """Test flavor extra spec operations by admin user
+
+        Test to SET, GET, UPDATE, SHOW, UNSET flavor extra
+        spec as a user with admin privileges.
+        """
         # Assigning extra specs values that are to be set
         specs = {'hw:numa_nodes': '1', 'hw:cpu_policy': 'shared'}
         # SET extra specs to the flavor created in setUp
@@ -100,6 +103,7 @@
 
     @decorators.idempotent_id('a99dad88-ae1c-4fba-aeb4-32f898218bd0')
     def test_flavor_non_admin_get_all_keys(self):
+        """Test non admin user getting all flavor extra spec keys"""
         specs = {'hw:numa_nodes': '1', 'hw:cpu_policy': 'shared'}
         self.admin_flavors_client.set_flavor_extra_spec(self.flavor['id'],
                                                         **specs)
@@ -111,6 +115,7 @@
 
     @decorators.idempotent_id('12805a7f-39a3-4042-b989-701d5cad9c90')
     def test_flavor_non_admin_get_specific_key(self):
+        """Test non admin user getting specific flavor extra spec key"""
         specs = {'hw:numa_nodes': '1', 'hw:cpu_policy': 'shared'}
         body = self.admin_flavors_client.set_flavor_extra_spec(
             self.flavor['id'], **specs
diff --git a/tempest/api/compute/admin/test_flavors_extra_specs_negative.py b/tempest/api/compute/admin/test_flavors_extra_specs_negative.py
index 9f89293..721acca 100644
--- a/tempest/api/compute/admin/test_flavors_extra_specs_negative.py
+++ b/tempest/api/compute/admin/test_flavors_extra_specs_negative.py
@@ -64,7 +64,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('a00a3b81-5641-45a8-ab2b-4a8ec41e1d7d')
     def test_flavor_non_admin_set_keys(self):
-        # Test to SET flavor extra spec as a user without admin privileges.
+        """Test to SET flavor extra spec as a user without admin privileges"""
         self.assertRaises(lib_exc.Forbidden,
                           self.flavors_client.set_flavor_extra_spec,
                           self.flavor['id'],
@@ -73,7 +73,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('1ebf4ef8-759e-48fe-a801-d451d80476fb')
     def test_flavor_non_admin_update_specific_key(self):
-        # non admin user is not allowed to update flavor extra spec
+        """non admin user is not allowed to update flavor extra spec"""
         body = self.admin_flavors_client.set_flavor_extra_spec(
             self.flavor['id'],
             **{'hw:numa_nodes': '1', 'hw:cpu_policy': 'shared'}
@@ -89,6 +89,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('28f12249-27c7-44c1-8810-1f382f316b11')
     def test_flavor_non_admin_unset_keys(self):
+        """non admin user is not allowed to unset flavor extra spec"""
         self.admin_flavors_client.set_flavor_extra_spec(
             self.flavor['id'],
             **{'hw:numa_nodes': '1', 'hw:cpu_policy': 'shared'}
@@ -102,6 +103,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('440b9f3f-3c7f-4293-a106-0ceda350f8de')
     def test_flavor_unset_nonexistent_key(self):
+        """Unsetting non existence flavor extra spec key should fail"""
         self.assertRaises(lib_exc.NotFound,
                           self.admin_flavors_client.unset_flavor_extra_spec,
                           self.flavor['id'],
@@ -110,6 +112,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('329a7be3-54b2-48be-8052-bf2ce4afd898')
     def test_flavor_get_nonexistent_key(self):
+        """Getting non existence flavor extra spec key should fail"""
         self.assertRaises(lib_exc.NotFound,
                           self.flavors_client.show_flavor_extra_spec,
                           self.flavor['id'],
@@ -118,7 +121,10 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('25b822b8-9f49-44f6-80de-d99f0482e5cb')
     def test_flavor_update_mismatch_key(self):
-        # the key will be updated should be match the key in the body
+        """Updating unmatched flavor extra spec key should fail
+
+        The key to be updated should match the key in the body
+        """
         self.assertRaises(lib_exc.BadRequest,
                           self.admin_flavors_client.update_flavor_extra_spec,
                           self.flavor['id'],
@@ -128,7 +134,10 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('f5889590-bf66-41cc-b4b1-6e6370cfd93f')
     def test_flavor_update_more_key(self):
-        # there should be just one item in the request body
+        """Updating multiple flavor spec keys should fail
+
+        There should be just one item in the request body
+        """
         self.assertRaises(lib_exc.BadRequest,
                           self.admin_flavors_client.update_flavor_extra_spec,
                           self.flavor['id'],
diff --git a/tempest/api/compute/admin/test_live_migration.py b/tempest/api/compute/admin/test_live_migration.py
index 836b975..a845c72 100644
--- a/tempest/api/compute/admin/test_live_migration.py
+++ b/tempest/api/compute/admin/test_live_migration.py
@@ -30,6 +30,7 @@
 
 
 class LiveMigrationTestBase(base.BaseV2ComputeAdminTest):
+    """Test live migration operations supported by admin user"""
 
     # These tests don't attempt any SSH validation nor do they use
     # floating IPs on the instance, so all we need is a network and
@@ -123,12 +124,14 @@
 
     @decorators.idempotent_id('1dce86b8-eb04-4c03-a9d8-9c1dc3ee0c7b')
     def test_live_block_migration(self):
+        """Test live migrating an active server"""
         self._test_live_migration()
 
     @decorators.idempotent_id('1e107f21-61b2-4988-8f22-b196e938ab88')
     @testtools.skipUnless(CONF.compute_feature_enabled.pause,
                           'Pause is not available.')
     def test_live_block_migration_paused(self):
+        """Test live migrating a paused server"""
         self._test_live_migration(state='PAUSED')
 
     @testtools.skipUnless(CONF.compute_feature_enabled.
@@ -137,6 +140,7 @@
     @decorators.idempotent_id('5071cf17-3004-4257-ae61-73a84e28badd')
     @utils.services('volume')
     def test_volume_backed_live_migration(self):
+        """Test live migrating an active server booted from volume"""
         self._test_live_migration(volume_backed=True)
 
     @decorators.idempotent_id('e19c0cc6-6720-4ed8-be83-b6603ed5c812')
@@ -148,6 +152,7 @@
                       'Block Live migration not configured for iSCSI')
     @utils.services('volume')
     def test_iscsi_volume(self):
+        """Test live migrating a server with volume attached"""
         server = self.create_test_server(wait_until="ACTIVE")
         server_id = server['id']
         target_host = self.get_host_other_than(server_id)
diff --git a/tempest/api/compute/admin/test_migrations.py b/tempest/api/compute/admin/test_migrations.py
index 83f2e61..37f5aec 100644
--- a/tempest/api/compute/admin/test_migrations.py
+++ b/tempest/api/compute/admin/test_migrations.py
@@ -25,6 +25,7 @@
 
 
 class MigrationsAdminTest(base.BaseV2ComputeAdminTest):
+    """Test migration operations supported by admin user"""
 
     @classmethod
     def setup_clients(cls):
@@ -33,14 +34,14 @@
 
     @decorators.idempotent_id('75c0b83d-72a0-4cf8-a153-631e83e7d53f')
     def test_list_migrations(self):
-        # Admin can get the migrations list
+        """Test admin user can get the migrations list"""
         self.client.list_migrations()
 
     @decorators.idempotent_id('1b512062-8093-438e-b47a-37d2f597cd64')
     @testtools.skipUnless(CONF.compute_feature_enabled.resize,
                           'Resize not available.')
     def test_list_migrations_in_flavor_resize_situation(self):
-        # Admin can get the migrations list which contains the resized server
+        """Admin can get the migrations list containing the resized server"""
         server = self.create_test_server(wait_until="ACTIVE")
         server_id = server['id']
 
@@ -62,8 +63,11 @@
     @testtools.skipUnless(CONF.compute_feature_enabled.resize,
                           'Resize not available.')
     def test_resize_server_revert_deleted_flavor(self):
-        # Tests that we can revert the resize on an instance whose original
-        # flavor has been deleted.
+        """Test reverting resized server with original flavor deleted
+
+        Tests that we can revert the resize on an instance whose original
+        flavor has been deleted.
+        """
 
         # First we have to create a flavor that we can delete so make a copy
         # of the normal flavor from which we'd create a server.
@@ -137,10 +141,12 @@
     @testtools.skipUnless(CONF.compute_feature_enabled.cold_migration,
                           'Cold migration not available.')
     def test_cold_migration(self):
+        """Test cold migrating server and then confirm the migration"""
         self._test_cold_migrate_server(revert=False)
 
     @decorators.idempotent_id('caa1aa8b-f4ef-4374-be0d-95f001c2ac2d')
     @testtools.skipUnless(CONF.compute_feature_enabled.cold_migration,
                           'Cold migration not available.')
     def test_revert_cold_migration(self):
+        """Test cold migrating server and then revert the migration"""
         self._test_cold_migrate_server(revert=True)
diff --git a/tempest/api/compute/admin/test_networks.py b/tempest/api/compute/admin/test_networks.py
index 33b23b5..fb6376e 100644
--- a/tempest/api/compute/admin/test_networks.py
+++ b/tempest/api/compute/admin/test_networks.py
@@ -35,6 +35,7 @@
 
     @decorators.idempotent_id('d206d211-8912-486f-86e2-a9d090d1f416')
     def test_get_network(self):
+        """Test getting network from nova side"""
         networks = self.client.list_networks()['networks']
         if CONF.compute.fixed_network_name:
             configured_network = [x for x in networks if x['label'] ==
@@ -56,6 +57,7 @@
 
     @decorators.idempotent_id('df3d1046-6fa5-4b2c-ad0c-cfa46a351cb9')
     def test_list_all_networks(self):
+        """Test getting all networks from nova side"""
         networks = self.client.list_networks()['networks']
         # Check the configured network is in the list
         if CONF.compute.fixed_network_name:
diff --git a/tempest/api/compute/admin/test_servers_on_multinodes.py b/tempest/api/compute/admin/test_servers_on_multinodes.py
index bebc8c5..f440428 100644
--- a/tempest/api/compute/admin/test_servers_on_multinodes.py
+++ b/tempest/api/compute/admin/test_servers_on_multinodes.py
@@ -23,7 +23,7 @@
 
 
 class ServersOnMultiNodesTest(base.BaseV2ComputeAdminTest):
-
+    """Test creating servers on mutiple nodes with scheduler_hints."""
     @classmethod
     def resource_setup(cls):
         super(ServersOnMultiNodesTest, cls).resource_setup()
@@ -65,6 +65,7 @@
         compute.is_scheduler_filter_enabled("SameHostFilter"),
         'SameHostFilter is not available.')
     def test_create_servers_on_same_host(self):
+        """Test creating servers with hints 'same_host'"""
         hints = {'same_host': self.server01}
         server02 = self.create_test_server(scheduler_hints=hints,
                                            wait_until='ACTIVE')['id']
@@ -76,6 +77,7 @@
         compute.is_scheduler_filter_enabled("DifferentHostFilter"),
         'DifferentHostFilter is not available.')
     def test_create_servers_on_different_hosts(self):
+        """Test creating servers with hints of single 'different_host'"""
         hints = {'different_host': self.server01}
         server02 = self.create_test_server(scheduler_hints=hints,
                                            wait_until='ACTIVE')['id']
@@ -87,7 +89,7 @@
         compute.is_scheduler_filter_enabled("DifferentHostFilter"),
         'DifferentHostFilter is not available.')
     def test_create_servers_on_different_hosts_with_list_of_servers(self):
-        # This scheduler-hint supports list of servers also.
+        """Test creating servers with hints of a list of 'different_host'"""
         hints = {'different_host': [self.server01]}
         server02 = self.create_test_server(scheduler_hints=hints,
                                            wait_until='ACTIVE')['id']
diff --git a/tempest/api/compute/admin/test_services_negative.py b/tempest/api/compute/admin/test_services_negative.py
index d264829..033caa8 100644
--- a/tempest/api/compute/admin/test_services_negative.py
+++ b/tempest/api/compute/admin/test_services_negative.py
@@ -31,14 +31,18 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('1126d1f8-266e-485f-a687-adc547492646')
     def test_list_services_with_non_admin_user(self):
+        """Non admin user is not allowed to list nova services"""
         self.assertRaises(lib_exc.Forbidden,
                           self.non_admin_client.list_services)
 
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('d0884a69-f693-4e79-a9af-232d15643bf7')
     def test_get_service_by_invalid_params(self):
-        # Expect all services to be returned when the request contains invalid
-        # parameters.
+        """Test listing services by invalid filter should return all services
+
+        Expect all services to be returned when the request contains invalid
+        parameters.
+        """
         services = self.client.list_services()['services']
         services_xxx = (self.client.list_services(xxx='nova-compute')
                         ['services'])
@@ -47,6 +51,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('1e966d4a-226e-47c7-b601-0b18a27add54')
     def test_get_service_by_invalid_service_and_valid_host(self):
+        """Test listing services by invalid service and valid host value"""
         services = self.client.list_services()['services']
         host_name = services[0]['host']
         services = self.client.list_services(host=host_name,
@@ -56,6 +61,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('64e7e7fb-69e8-4cb6-a71d-8d5eb0c98655')
     def test_get_service_with_valid_service_and_invalid_host(self):
+        """Test listing services by valid service and invalid host value"""
         services = self.client.list_services()['services']
         binary_name = services[0]['binary']
         services = self.client.list_services(host='xxx',
@@ -79,6 +85,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('508671aa-c929-4479-bd10-8680d40dd0a6')
     def test_enable_service_with_invalid_service_id(self):
+        """Test updating non existing service to status enabled"""
         self.assertRaises(lib_exc.NotFound,
                           self.client.update_service,
                           service_id=self.fake_service_id,
@@ -87,6 +94,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('a9eeeade-42b3-419f-87aa-c9342aa068cf')
     def test_disable_service_with_invalid_service_id(self):
+        """Test updating non existing service to status disabled"""
         self.assertRaises(lib_exc.NotFound,
                           self.client.update_service,
                           service_id=self.fake_service_id,
@@ -95,6 +103,8 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('f46a9d91-1e85-4b96-8e7a-db7706fa2e9a')
     def test_disable_log_reason_with_invalid_service_id(self):
+        """Test updating non existing service to disabled with reason"""
+
         # disabled_reason requires that status='disabled' be provided.
         self.assertRaises(lib_exc.NotFound,
                           self.client.update_service,
diff --git a/tempest/api/compute/admin/test_volumes_negative.py b/tempest/api/compute/admin/test_volumes_negative.py
index 133f4bc..7b0f48b 100644
--- a/tempest/api/compute/admin/test_volumes_negative.py
+++ b/tempest/api/compute/admin/test_volumes_negative.py
@@ -67,10 +67,10 @@
     volume_min_microversion = '3.27'
 
     @classmethod
-    def skip_checks(self):
-        super(UpdateMultiattachVolumeNegativeTest, self).skip_checks()
+    def skip_checks(cls):
+        super(UpdateMultiattachVolumeNegativeTest, cls).skip_checks()
         if not CONF.compute_feature_enabled.volume_multiattach:
-            raise self.skipException('Volume multi-attach is not available.')
+            raise cls.skipException('Volume multi-attach is not available.')
 
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('7576d497-b7c6-44bd-9cc5-c5b4e50fec71')
diff --git a/tempest/api/compute/servers/test_device_tagging.py b/tempest/api/compute/servers/test_device_tagging.py
index 1f7eb7b..8879369 100644
--- a/tempest/api/compute/servers/test_device_tagging.py
+++ b/tempest/api/compute/servers/test_device_tagging.py
@@ -12,6 +12,8 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+from json import decoder as json_decoder
+
 from oslo_log import log as logging
 from oslo_serialization import jsonutils as json
 
@@ -110,7 +112,11 @@
     max_microversion = '2.32'
 
     def verify_device_metadata(self, md_json):
-        md_dict = json.loads(md_json)
+        try:
+            md_dict = json.loads(md_json)
+        except (json_decoder.JSONDecodeError, TypeError):
+            return False
+
         for d in md_dict['devices']:
             if d['type'] == 'nic':
                 if d['mac'] == self.port1['mac_address']:
@@ -310,7 +316,11 @@
             raise cls.skipException('Metadata API must be enabled')
 
     def verify_device_metadata(self, md_json):
-        md_dict = json.loads(md_json)
+        try:
+            md_dict = json.loads(md_json)
+        except (json_decoder.JSONDecodeError, TypeError):
+            return False
+
         found_devices = [d['tags'][0] for d in md_dict['devices']
                          if d.get('tags')]
         try:
diff --git a/tempest/api/compute/servers/test_server_rescue.py b/tempest/api/compute/servers/test_server_rescue.py
index 6629794..1ac9516 100644
--- a/tempest/api/compute/servers/test_server_rescue.py
+++ b/tempest/api/compute/servers/test_server_rescue.py
@@ -103,3 +103,75 @@
         # Delete Security group
         self.servers_client.remove_security_group(self.rescued_server_id,
                                                   name=sg['name'])
+
+
+class ServerStableDeviceRescueTest(base.BaseV2ComputeTest):
+
+    @classmethod
+    def skip_checks(self):
+        super(ServerStableDeviceRescueTest, self).skip_checks()
+        if not CONF.compute_feature_enabled.rescue:
+            msg = "Server rescue not available."
+            raise self.skipException(msg)
+        if not CONF.compute_feature_enabled.stable_rescue:
+            msg = "Stable rescue not available."
+            raise self.skipException(msg)
+
+    def _create_server_and_rescue_image(self, hw_rescue_device=None,
+                                        hw_rescue_bus=None):
+        server_id = self.create_test_server(wait_until='ACTIVE')['id']
+        image_id = self.create_image_from_server(server_id,
+                                                 wait_until='ACTIVE')['id']
+        if hw_rescue_bus:
+            self.images_client.update_image(
+                image_id, [dict(add='/hw_rescue_bus',
+                                value=hw_rescue_bus)])
+        if hw_rescue_device:
+            self.images_client.update_image(
+                image_id, [dict(add='/hw_rescue_device',
+                                value=hw_rescue_device)])
+        return server_id, image_id
+
+    def _test_stable_device_rescue(self, server_id, rescue_image_id):
+        self.servers_client.rescue_server(
+            server_id, rescue_image_ref=rescue_image_id)
+        waiters.wait_for_server_status(
+            self.servers_client, server_id, 'RESCUE')
+        self.servers_client.unrescue_server(server_id)
+        waiters.wait_for_server_status(
+            self.servers_client, server_id, 'ACTIVE')
+
+    @decorators.idempotent_id('947004c3-e8ef-47d9-9f00-97b74f9eaf96')
+    def test_stable_device_rescue_cdrom_ide(self):
+        server_id, rescue_image_id = self._create_server_and_rescue_image(
+            hw_rescue_device='cdrom', hw_rescue_bus='ide')
+        self._test_stable_device_rescue(server_id, rescue_image_id)
+
+    @decorators.idempotent_id('16865750-1417-4854-bcf7-496e6753c01e')
+    def test_stable_device_rescue_disk_virtio(self):
+        server_id, rescue_image_id = self._create_server_and_rescue_image(
+            hw_rescue_device='disk', hw_rescue_bus='virtio')
+        self._test_stable_device_rescue(server_id, rescue_image_id)
+
+    @decorators.idempotent_id('12340157-6306-4745-bdda-cfa019908b48')
+    def test_stable_device_rescue_disk_scsi(self):
+        server_id, rescue_image_id = self._create_server_and_rescue_image(
+            hw_rescue_device='disk', hw_rescue_bus='scsi')
+        self._test_stable_device_rescue(server_id, rescue_image_id)
+
+    @decorators.idempotent_id('647d04cf-ad35-4956-89ab-b05c5c16f30c')
+    def test_stable_device_rescue_disk_usb(self):
+        server_id, rescue_image_id = self._create_server_and_rescue_image(
+            hw_rescue_device='disk', hw_rescue_bus='usb')
+        self._test_stable_device_rescue(server_id, rescue_image_id)
+
+    @decorators.idempotent_id('a3772b42-00bf-4310-a90b-1cc6fd3e7eab')
+    def test_stable_device_rescue_disk_virtio_with_volume_attached(self):
+        server_id, rescue_image_id = self._create_server_and_rescue_image(
+            hw_rescue_device='disk', hw_rescue_bus='virtio')
+        server = self.servers_client.show_server(server_id)['server']
+        volume = self.create_volume()
+        self.attach_volume(server, volume)
+        waiters.wait_for_volume_resource_status(self.volumes_client,
+                                                volume['id'], 'in-use')
+        self._test_stable_device_rescue(server_id, rescue_image_id)
diff --git a/tempest/api/identity/admin/v2/test_endpoints.py b/tempest/api/identity/admin/v2/test_endpoints.py
index 947706e..236ce7c 100644
--- a/tempest/api/identity/admin/v2/test_endpoints.py
+++ b/tempest/api/identity/admin/v2/test_endpoints.py
@@ -19,6 +19,7 @@
 
 
 class EndPointsTestJSON(base.BaseIdentityV2AdminTest):
+    """Test keystone v2 endpoints"""
 
     @classmethod
     def resource_setup(cls):
@@ -51,6 +52,7 @@
 
     @decorators.idempotent_id('11f590eb-59d8-4067-8b2b-980c7f387f51')
     def test_list_endpoints(self):
+        """Test listing keystone endpoints"""
         # Get a list of endpoints
         fetched_endpoints = self.endpoints_client.list_endpoints()['endpoints']
         # Asserting LIST endpoints
@@ -62,6 +64,7 @@
 
     @decorators.idempotent_id('9974530a-aa28-4362-8403-f06db02b26c1')
     def test_create_list_delete_endpoint(self):
+        """Test creating, listing and deleting a keystone endpoint"""
         region = data_utils.rand_name('region')
         url = data_utils.rand_url()
         endpoint = self.endpoints_client.create_endpoint(
diff --git a/tempest/api/identity/admin/v3/test_default_project_id.py b/tempest/api/identity/admin/v3/test_default_project_id.py
index 73fddb7..7c3a6cc 100644
--- a/tempest/api/identity/admin/v3/test_default_project_id.py
+++ b/tempest/api/identity/admin/v3/test_default_project_id.py
@@ -22,6 +22,7 @@
 
 
 class TestDefaultProjectId(base.BaseIdentityV3AdminTest):
+    """Test creating a token without project will default to user's project"""
 
     @classmethod
     def setup_credentials(cls):
@@ -35,11 +36,11 @@
         self.domains_client.delete_domain(domain_id)
 
     @testtools.skipIf(CONF.identity_feature_enabled.immutable_user_source,
-                      'Skipped because environment has an '
-                      'immutable user source and solely '
-                      'provides read-only access to users.')
+                      'Skipped because environment has an immutable user '
+                      'source and solely provides read-only access to users.')
     @decorators.idempotent_id('d6110661-6a71-49a7-a453-b5e26640ff6d')
     def test_default_project_id(self):
+        """Creating a token without project will default to user's project"""
         # create a domain
         dom_name = data_utils.rand_name('dom')
         domain_body = self.domains_client.create_domain(
diff --git a/tempest/api/identity/admin/v3/test_list_users.py b/tempest/api/identity/admin/v3/test_list_users.py
index 5aec931..7bd0bcf 100644
--- a/tempest/api/identity/admin/v3/test_list_users.py
+++ b/tempest/api/identity/admin/v3/test_list_users.py
@@ -22,6 +22,7 @@
 
 
 class UsersV3TestJSON(base.BaseIdentityV3AdminTest):
+    """Test listing keystone users"""
 
     def _list_users_with_params(self, params, key, expected, not_expected):
         # Helper method to list users filtered with params and
@@ -69,7 +70,7 @@
 
     @decorators.idempotent_id('08f9aabb-dcfe-41d0-8172-82b5fa0bd73d')
     def test_list_user_domains(self):
-        # List users with domain
+        """List users with domain"""
         params = {'domain_id': self.domain['id']}
         self._list_users_with_params(params, 'domain_id',
                                      self.domain_enabled_user,
@@ -77,7 +78,7 @@
 
     @decorators.idempotent_id('bff8bf2f-9408-4ef5-b63a-753c8c2124eb')
     def test_list_users_with_not_enabled(self):
-        # List the users with not enabled
+        """List the users with not enabled"""
         params = {'enabled': False}
         self._list_users_with_params(params, 'enabled',
                                      self.non_domain_enabled_user,
@@ -85,7 +86,7 @@
 
     @decorators.idempotent_id('c285bb37-7325-4c02-bff3-3da5d946d683')
     def test_list_users_with_name(self):
-        # List users with name
+        """List users with name"""
         params = {'name': self.domain_enabled_user['name']}
         # When domain specific drivers are enabled the operations
         # of listing all users and listing all groups are not supported,
@@ -98,7 +99,7 @@
 
     @decorators.idempotent_id('b30d4651-a2ea-4666-8551-0c0e49692635')
     def test_list_users(self):
-        # List users
+        """List users"""
         # When domain specific drivers are enabled the operations
         # of listing all users and listing all groups are not supported,
         # they need a domain filter to be specified
@@ -120,7 +121,7 @@
 
     @decorators.idempotent_id('b4baa3ae-ac00-4b4e-9e27-80deaad7771f')
     def test_get_user(self):
-        # Get a user detail
+        """Get a user detail"""
         user = self.users_client.show_user(self.users[0]['id'])['user']
         self.assertEqual(self.users[0]['id'], user['id'])
         self.assertEqual(self.users[0]['name'], user['name'])
diff --git a/tempest/api/identity/admin/v3/test_policies.py b/tempest/api/identity/admin/v3/test_policies.py
index 2908fc4..fb81d0a 100644
--- a/tempest/api/identity/admin/v3/test_policies.py
+++ b/tempest/api/identity/admin/v3/test_policies.py
@@ -19,13 +19,14 @@
 
 
 class PoliciesTestJSON(base.BaseIdentityV3AdminTest):
+    """Test keystone policies"""
 
     def _delete_policy(self, policy_id):
         self.policies_client.delete_policy(policy_id)
 
     @decorators.idempotent_id('1a0ad286-2d06-4123-ab0d-728893a76201')
     def test_list_policies(self):
-        # Test to list policies
+        """Test to list keystone policies"""
         policy_ids = list()
         fetched_ids = list()
         for _ in range(3):
@@ -46,7 +47,7 @@
     @decorators.attr(type='smoke')
     @decorators.idempotent_id('e544703a-2f03-4cf2-9b0f-350782fdb0d3')
     def test_create_update_delete_policy(self):
-        # Test to update policy
+        """Test to update keystone policy"""
         blob = data_utils.rand_name('BlobName')
         policy_type = data_utils.rand_name('PolicyType')
         policy = self.policies_client.create_policy(blob=blob,
diff --git a/tempest/api/identity/admin/v3/test_project_tags.py b/tempest/api/identity/admin/v3/test_project_tags.py
index b7878a8..eed60af 100644
--- a/tempest/api/identity/admin/v3/test_project_tags.py
+++ b/tempest/api/identity/admin/v3/test_project_tags.py
@@ -25,6 +25,8 @@
 
 
 class IdentityV3ProjectTagsTest(base.BaseIdentityV3AdminTest):
+    """Test keystone project tags"""
+
     # NOTE: force_tenant_isolation is true in the base class by default but
     # overridden to false here to allow test execution for clouds using the
     # pre-provisioned credentials provider.
@@ -34,6 +36,7 @@
     @testtools.skipUnless(CONF.identity_feature_enabled.project_tags,
                           'Project tags not available.')
     def test_list_update_delete_project_tags(self):
+        """Test listing, updating and deleting of project tags"""
         project = self.setup_test_project()
 
         # Create a tag for testing.
diff --git a/tempest/api/identity/admin/v3/test_services.py b/tempest/api/identity/admin/v3/test_services.py
index 5afeb98..a649d27 100644
--- a/tempest/api/identity/admin/v3/test_services.py
+++ b/tempest/api/identity/admin/v3/test_services.py
@@ -20,6 +20,7 @@
 
 
 class ServicesTestJSON(base.BaseIdentityV3AdminTest):
+    """Test keystone services"""
 
     def _del_service(self, service_id):
         # Used for deleting the services created in this class
@@ -31,6 +32,7 @@
     @decorators.attr(type='smoke')
     @decorators.idempotent_id('5193aad5-bcb7-411d-85b0-b3b61b96ef06')
     def test_create_update_get_service(self):
+        """Test creating, updating and getting of keystone service"""
         # Creating a Service
         name = data_utils.rand_name('service')
         serv_type = data_utils.rand_name('type')
@@ -63,7 +65,7 @@
 
     @decorators.idempotent_id('d1dcb1a1-2b6b-4da8-bbb8-5532ef6e8269')
     def test_create_service_without_description(self):
-        # Create a service only with name and type
+        """Create a keystone service only with name and type"""
         name = data_utils.rand_name('service')
         serv_type = data_utils.rand_name('type')
         service = self.services_client.create_service(
@@ -74,7 +76,7 @@
 
     @decorators.idempotent_id('e55908e8-360e-439e-8719-c3230a3e179e')
     def test_list_services(self):
-        # Create, List, Verify and Delete Services
+        """Create, List, Verify and Delete Keystone Services"""
         service_ids = list()
         service_types = list()
         for _ in range(3):
diff --git a/tempest/api/identity/admin/v3/test_users.py b/tempest/api/identity/admin/v3/test_users.py
index 8955a93..31cbbac 100644
--- a/tempest/api/identity/admin/v3/test_users.py
+++ b/tempest/api/identity/admin/v3/test_users.py
@@ -27,6 +27,7 @@
 
 
 class UsersV3TestJSON(base.BaseIdentityV3AdminTest):
+    """Test keystone users"""
 
     @classmethod
     def skip_checks(cls):
@@ -38,7 +39,7 @@
 
     @decorators.idempotent_id('b537d090-afb9-4519-b95d-270b0708e87e')
     def test_user_update(self):
-        # Test case to check if updating of user attributes is successful.
+        """Test case to check if updating of user attributes is successful"""
         # Creating first user
         u_name = data_utils.rand_name('user')
         u_desc = u_name + 'description'
@@ -72,6 +73,7 @@
 
     @decorators.idempotent_id('2d223a0e-e457-4a70-9fb1-febe027a0ff9')
     def test_update_user_password(self):
+        """Test updating user password"""
         # Creating User to check password updation
         u_name = data_utils.rand_name('user')
         original_password = data_utils.rand_password()
@@ -98,7 +100,7 @@
 
     @decorators.idempotent_id('a831e70c-e35b-430b-92ed-81ebbc5437b8')
     def test_list_user_projects(self):
-        # List the projects that a user has access upon
+        """Test listing the projects that a user has access upon"""
         assigned_project_ids = list()
         fetched_project_ids = list()
         u_project = self.setup_test_project()
@@ -141,7 +143,7 @@
 
     @decorators.idempotent_id('c10dcd90-461d-4b16-8e23-4eb836c00644')
     def test_get_user(self):
-        # Get a user detail
+        """Test getting a user detail"""
         user = self.setup_test_user()
         fetched_user = self.users_client.show_user(user['id'])['user']
         self.assertEqual(user['id'], fetched_user['id'])
@@ -150,6 +152,7 @@
                           'Security compliance not available.')
     @decorators.idempotent_id('568cd46c-ee6c-4ab4-a33a-d3791931979e')
     def test_password_history_not_enforced_in_admin_reset(self):
+        """Test setting same password when password history is not enforced"""
         old_password = self.os_primary.credentials.password
         user_id = self.os_primary.credentials.user_id
 
diff --git a/tempest/api/identity/v3/test_catalog.py b/tempest/api/identity/v3/test_catalog.py
index bc95f0d..ce6adf9 100644
--- a/tempest/api/identity/v3/test_catalog.py
+++ b/tempest/api/identity/v3/test_catalog.py
@@ -19,9 +19,11 @@
 
 
 class IdentityCatalogTest(base.BaseIdentityV3Test):
+    """Test service's catalog type values"""
 
     @decorators.idempotent_id('56b57ced-22b8-4127-9b8a-565dfb0207e2')
     def test_catalog_standardization(self):
+        """Test that every service has a standard catalog type value"""
         # https://opendev.org/openstack/service-types-authority
         # /src/branch/master/service-types.yaml
         standard_service_values = [{'name': 'keystone', 'type': 'identity'},
@@ -31,11 +33,9 @@
         # next, we need to GET the catalog using the catalog client
         catalog = self.non_admin_catalog_client.show_catalog()['catalog']
         # get list of the service types present in the catalog
-        catalog_services = []
-        for service in catalog:
-            catalog_services.append(service['type'])
+        catalog_services = [service['type'] for service in catalog]
         for service in standard_service_values:
-            # if service enabled, check if it has a standard typevalue
+            # if service enabled, check if it has a standard type value
             if service['name'] == 'keystone' or\
                     getattr(CONF.service_available, service['name']):
                 self.assertIn(service['type'], catalog_services)
diff --git a/tempest/api/volume/test_versions.py b/tempest/api/volume/test_versions.py
index b602032..1e5c9de 100644
--- a/tempest/api/volume/test_versions.py
+++ b/tempest/api/volume/test_versions.py
@@ -17,12 +17,14 @@
 
 
 class VersionsTest(base.BaseVolumeTest):
+    """Test cinder versions"""
 
     _api_version = 3
 
     @decorators.idempotent_id('77838fc4-b49b-4c64-9533-166762517369')
     @decorators.attr(type='smoke')
     def test_list_versions(self):
+        """Test listing cinder versions"""
         # NOTE: The version data is checked on service client side
         #       with JSON-Schema validation. It is enough to just call
         #       the API here.
@@ -30,6 +32,7 @@
 
     @decorators.idempotent_id('7f755ae2-caa9-4049-988c-331d8f7a579f')
     def test_show_version(self):
+        "Test getting cinder version details"
         # NOTE: The version data is checked on service client side
         # with JSON-Schema validation. So we will loop through each
         # version and call show version.
diff --git a/tempest/api/volume/test_volume_delete_cascade.py b/tempest/api/volume/test_volume_delete_cascade.py
index bb32c11..53f1bca 100644
--- a/tempest/api/volume/test_volume_delete_cascade.py
+++ b/tempest/api/volume/test_volume_delete_cascade.py
@@ -58,8 +58,11 @@
 
     @decorators.idempotent_id('994e2d40-de37-46e8-b328-a58fba7e4a95')
     def test_volume_delete_cascade(self):
-        # The case validates the ability to delete a volume
-        # with associated snapshots.
+        """Test deleting a volume with associated snapshots
+
+        The case validates the ability to delete a volume
+        with associated snapshots.
+        """
 
         # Create a volume
         volume = self.create_volume()
@@ -78,9 +81,12 @@
     @testtools.skipIf(CONF.volume.storage_protocol == 'ceph',
                       'Skip because of Bug#1677525')
     def test_volume_from_snapshot_cascade_delete(self):
-        # The case validates the ability to delete a volume with
-        # associated snapshot while there is another volume created
-        # from that snapshot.
+        """Test deleting a volume with associated volume-associated snapshot
+
+        The case validates the ability to delete a volume with
+        associated snapshot while there is another volume created
+        from that snapshot.
+        """
 
         # Create a volume
         volume = self.create_volume()
diff --git a/tempest/api/volume/test_volume_metadata.py b/tempest/api/volume/test_volume_metadata.py
index d203b2d..2151168 100644
--- a/tempest/api/volume/test_volume_metadata.py
+++ b/tempest/api/volume/test_volume_metadata.py
@@ -20,6 +20,7 @@
 
 
 class VolumesMetadataTest(base.BaseVolumeTest):
+    """Test volume metadata"""
 
     @classmethod
     def resource_setup(cls):
@@ -34,6 +35,7 @@
 
     @decorators.idempotent_id('6f5b125b-f664-44bf-910f-751591fe5769')
     def test_crud_volume_metadata(self):
+        """Test creating, getting, updating and deleting of volume metadata"""
         # Create metadata for the volume
         metadata = {"key1": "value1",
                     "key2": "value2",
@@ -71,6 +73,7 @@
 
     @decorators.idempotent_id('862261c5-8df4-475a-8c21-946e50e36a20')
     def test_update_show_volume_metadata_item(self):
+        """Test updating and getting single volume metadata item"""
         # Update metadata item for the volume
         metadata = {"key1": "value1",
                     "key2": "value2",
diff --git a/tempest/api/volume/test_volume_transfers.py b/tempest/api/volume/test_volume_transfers.py
index 4cdf898..3eb81f5 100644
--- a/tempest/api/volume/test_volume_transfers.py
+++ b/tempest/api/volume/test_volume_transfers.py
@@ -20,6 +20,7 @@
 
 
 class VolumesTransfersTest(base.BaseVolumeTest):
+    """Test volume transfer"""
 
     credentials = ['primary', 'alt', 'admin']
 
@@ -34,6 +35,7 @@
 
     @decorators.idempotent_id('4d75b645-a478-48b1-97c8-503f64242f1a')
     def test_create_get_list_accept_volume_transfer(self):
+        """Test creating, getting, listing and accepting of volume transfer"""
         # Create a volume first
         volume = self.create_volume()
         self.addCleanup(self.delete_volume,
@@ -74,6 +76,7 @@
 
     @decorators.idempotent_id('ab526943-b725-4c07-b875-8e8ef87a2c30')
     def test_create_list_delete_volume_transfer(self):
+        """Test creating, listing and deleting volume transfer"""
         # Create a volume first
         volume = self.create_volume()
         self.addCleanup(self.delete_volume,
diff --git a/tempest/api/volume/test_volumes_clone.py b/tempest/api/volume/test_volumes_clone.py
index ea39a21..eb54426 100644
--- a/tempest/api/volume/test_volumes_clone.py
+++ b/tempest/api/volume/test_volumes_clone.py
@@ -23,6 +23,7 @@
 
 
 class VolumesCloneTest(base.BaseVolumeTest):
+    """Test volume clone"""
 
     @classmethod
     def skip_checks(cls):
@@ -44,6 +45,7 @@
 
     @decorators.idempotent_id('9adae371-a257-43a5-9555-dc7c88e66e0e')
     def test_create_from_volume(self):
+        """Test cloning a volume with increasing size"""
         # Creates a volume from another volume passing a size different from
         # the source volume.
         src_size = CONF.volume.volume_size
@@ -58,6 +60,7 @@
     @decorators.idempotent_id('cbbcd7c6-5a6c-481a-97ac-ca55ab715d16')
     @utils.services('image')
     def test_create_from_bootable_volume(self):
+        """Test cloning a bootable volume"""
         # Create volume from image
         img_uuid = CONF.compute.image_ref
         src_vol = self.create_volume(imageRef=img_uuid)
diff --git a/tempest/api/volume/test_volumes_clone_negative.py b/tempest/api/volume/test_volumes_clone_negative.py
index bba7a0b..4bfb166 100644
--- a/tempest/api/volume/test_volumes_clone_negative.py
+++ b/tempest/api/volume/test_volumes_clone_negative.py
@@ -22,6 +22,7 @@
 
 
 class VolumesCloneNegativeTest(base.BaseVolumeTest):
+    """Negative tests of volume clone"""
 
     @classmethod
     def skip_checks(cls):
@@ -32,6 +33,7 @@
     @decorators.attr(type=['negative'])
     @decorators.idempotent_id('9adae371-a257-43a5-459a-dc7c88e66e0e')
     def test_create_from_volume_decreasing_size(self):
+        """Test cloning a volume with decreasing size will fail"""
         # Creates a volume from another volume passing a size different from
         # the source volume.
         src_size = CONF.volume.volume_size + 1
diff --git a/tempest/api/volume/test_volumes_extend.py b/tempest/api/volume/test_volumes_extend.py
index 2267045..041823d 100644
--- a/tempest/api/volume/test_volumes_extend.py
+++ b/tempest/api/volume/test_volumes_extend.py
@@ -28,9 +28,11 @@
 
 
 class VolumesExtendTest(base.BaseVolumeTest):
+    """Test volume extend"""
 
     @decorators.idempotent_id('9a36df71-a257-43a5-9555-dc7c88e66e0e')
     def test_volume_extend(self):
+        """Test extend a volume"""
         # Extend Volume Test.
         volume = self.create_volume(imageRef=self.image_ref)
         extend_size = volume['size'] * 2
@@ -45,6 +47,7 @@
     @testtools.skipUnless(CONF.volume_feature_enabled.snapshot,
                           "Cinder volume snapshots are disabled")
     def test_volume_extend_when_volume_has_snapshot(self):
+        """Test extending a volume which has a snapshot"""
         volume = self.create_volume()
         self.create_snapshot(volume['id'])
 
diff --git a/tempest/api/volume/test_volumes_get.py b/tempest/api/volume/test_volumes_get.py
index 71db95c..ade2deb 100644
--- a/tempest/api/volume/test_volumes_get.py
+++ b/tempest/api/volume/test_volumes_get.py
@@ -27,6 +27,7 @@
 
 
 class VolumesGetTest(base.BaseVolumeTest):
+    """Test getting volume info"""
 
     def _volume_create_get_update_delete(self, **kwargs):
         # Create a volume, Get it's details and Delete the volume
@@ -118,12 +119,14 @@
     @decorators.attr(type='smoke')
     @decorators.idempotent_id('27fb0e9f-fb64-41dd-8bdb-1ffa762f0d51')
     def test_volume_create_get_update_delete(self):
+        """Test Create/Get/Update/Delete of a blank volume"""
         self._volume_create_get_update_delete(size=CONF.volume.volume_size)
 
     @decorators.attr(type='smoke')
     @decorators.idempotent_id('54a01030-c7fc-447c-86ee-c1182beae638')
     @utils.services('image')
     def test_volume_create_get_update_delete_from_image(self):
+        """Test Create/Get/Update/Delete of a volume created from image"""
         image = self.images_client.show_image(CONF.compute.image_ref)
         min_disk = image['min_disk']
         disk_size = max(min_disk, CONF.volume.volume_size)
@@ -134,12 +137,14 @@
     @testtools.skipUnless(CONF.volume_feature_enabled.clone,
                           'Cinder volume clones are disabled')
     def test_volume_create_get_update_delete_as_clone(self):
+        """Test Create/Get/Update/Delete of a cloned volume"""
         origin = self.create_volume()
         self._volume_create_get_update_delete(source_volid=origin['id'],
                                               size=CONF.volume.volume_size)
 
 
 class VolumesSummaryTest(base.BaseVolumeTest):
+    """Test volume summary"""
 
     _api_version = 3
     min_microversion = '3.12'
@@ -147,6 +152,7 @@
 
     @decorators.idempotent_id('c4f2431e-4920-4736-9e00-4040386b6feb')
     def test_show_volume_summary(self):
+        """Test showing volume summary"""
         volume_summary = \
             self.volumes_client.show_volume_summary()['volume-summary']
         for key in ['total_size', 'total_count']:
diff --git a/tempest/cmd/run.py b/tempest/cmd/run.py
index f9ca2c7..d82b6df 100644
--- a/tempest/cmd/run.py
+++ b/tempest/cmd/run.py
@@ -47,6 +47,42 @@
 by removing unnecessary tests from a list file which is generated from
 ``--list-tests`` option.
 
+You can also use ``--worker-file`` option that let you pass a filepath to a
+worker yaml file, allowing you to manually schedule the tests run.
+For example, you can setup a tempest run with
+different concurrences to be used with different regexps.
+An example of worker file is showed below::
+
+    # YAML Worker file
+    - worker:
+      # you can have more than one regex per worker
+      - tempest.api.*
+      - neutron_tempest_tests
+    - worker:
+      - tempest.scenario.*
+
+This will run test matching with 'tempest.api.*' and 'neutron_tempest_tests'
+against worker 1. Run tests matching with 'tempest.scenario.*' under worker 2.
+
+You can mix manual scheduling with the standard scheduling mechanisms by
+concurrency field on a worker. For example::
+
+    # YAML Worker file
+    - worker:
+      # you can have more than one regex per worker
+      - tempest.api.*
+      - neutron_tempest_tests
+      concurrency: 3
+    - worker:
+      - tempest.scenario.*
+      concurrency: 2
+
+This will run tests matching with 'tempest.scenario.*' against 2 workers.
+
+This worker file is passed into stestr. For some more details on how it
+operates please refer to the stestr scheduling docs:
+https://stestr.readthedocs.io/en/stable/MANUAL.html#test-scheduling
+
 Test Execution
 ==============
 There are several options to control how the tests are executed. By default
@@ -185,6 +221,7 @@
                 blacklist_file=parsed_args.blacklist_file,
                 whitelist_file=parsed_args.whitelist_file,
                 black_regex=parsed_args.black_regex,
+                worker_path=parsed_args.worker_file,
                 load_list=parsed_args.load_list, combine=parsed_args.combine)
             if return_code > 0:
                 sys.exit(return_code)
@@ -254,6 +291,10 @@
                                  'on each newline. This command '
                                  'supports files created by the tempest '
                                  'run ``--list-tests`` command')
+        parser.add_argument('--worker-file', '--worker_file',
+                            help='Optional path to a worker file. This file '
+                            'contains each worker configuration to be '
+                            'used to schedule the tests run')
         # list only args
         parser.add_argument('--list-tests', '-l', action='store_true',
                             help='List tests',
diff --git a/tempest/common/compute.py b/tempest/common/compute.py
index cd85ede..edb9d16 100644
--- a/tempest/common/compute.py
+++ b/tempest/common/compute.py
@@ -400,9 +400,24 @@
         """Upgrade the HTTP connection to a WebSocket and verify."""
         # It is possible to pass the path as a query parameter in the request,
         # so use it if present
+        # Given noVNC format
+        # https://x.com/vnc_auto.html?path=%3Ftoken%3Dxxx,
+        # url format is
+        # ParseResult(scheme='https', netloc='x.com',
+        # path='/vnc_auto.html', params='',
+        # query='path=%3Ftoken%3Dxxx', fragment='').
+        # qparams format is {'path': ['?token=xxx']}
         qparams = urlparse.parse_qs(url.query)
-        path = qparams['path'][0] if 'path' in qparams else '/websockify'
-        reqdata = 'GET %s HTTP/1.1\r\n' % path
+        # according to references
+        # https://docs.python.org/3/library/urllib.parse.html
+        # https://tools.ietf.org/html/rfc3986#section-3.4
+        # qparams['path'][0] format is '?token=xxx' without / prefix
+        # remove / in /websockify to comply to references.
+        path = qparams['path'][0] if 'path' in qparams else 'websockify'
+        # Fix websocket request format by adding / prefix.
+        # Updated request format: GET /?token=xxx HTTP/1.1
+        # or GET /websockify HTTP/1.1
+        reqdata = 'GET /%s HTTP/1.1\r\n' % path
         reqdata += 'Host: %s' % url.hostname
         # Add port only if we have one specified
         if url.port:
diff --git a/tempest/common/waiters.py b/tempest/common/waiters.py
index e242301..14790d6 100644
--- a/tempest/common/waiters.py
+++ b/tempest/common/waiters.py
@@ -230,7 +230,7 @@
     while any(attachment_id == a['attachment_id'] for a in attachments):
         time.sleep(client.build_interval)
         if int(time.time()) - start >= client.build_timeout:
-            message = ('Failed to remove attachment %s from volume %s'
+            message = ('Failed to remove attachment %s from volume %s '
                        'within the required time (%s s).' %
                        (attachment_id, volume_id, client.build_timeout))
             raise lib_exc.TimeoutException(message)
diff --git a/tempest/config.py b/tempest/config.py
index 9685745..1699c7d 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -521,6 +521,10 @@
                 default=True,
                 help='Does the test environment support instance rescue '
                      'mode?'),
+    cfg.BoolOpt('stable_rescue',
+                default=False,
+                help='Does the test environment support stable device '
+                     'instance rescue mode?'),
     cfg.BoolOpt('enable_instance_password',
                 default=True,
                 help='Enables returning of the instance password by the '
diff --git a/tempest/lib/api_schema/response/compute/v2_73/__init__.py b/tempest/lib/api_schema/response/compute/v2_73/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/lib/api_schema/response/compute/v2_73/__init__.py
diff --git a/tempest/lib/api_schema/response/compute/v2_73/servers.py b/tempest/lib/api_schema/response/compute/v2_73/servers.py
new file mode 100644
index 0000000..6e491e9
--- /dev/null
+++ b/tempest/lib/api_schema/response/compute/v2_73/servers.py
@@ -0,0 +1,78 @@
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import copy
+
+from tempest.lib.api_schema.response.compute.v2_71 import servers as servers271
+
+
+###########################################################################
+#
+# 2.73:
+#
+# The locked_reason parameter is now returned in the response body of the
+# following calls:
+#
+# - POST /servers/{server_id}/action (where the action is rebuild)
+# - PUT /servers/{server_id} (update)
+# - GET /servers/{server_id} (show)
+# - GET /servers/detail (list)
+#
+###########################################################################
+
+# The "locked_reason" parameter will either be a string or None.
+locked_reason = {'type': ['string', 'null']}
+
+rebuild_server = copy.deepcopy(servers271.rebuild_server)
+rebuild_server['response_body']['properties']['server'][
+    'properties'].update({'locked_reason': locked_reason})
+rebuild_server['response_body']['properties']['server'][
+    'required'].append('locked_reason')
+
+rebuild_server_with_admin_pass = copy.deepcopy(
+    servers271.rebuild_server_with_admin_pass)
+rebuild_server_with_admin_pass['response_body']['properties']['server'][
+    'properties'].update({'locked_reason': locked_reason})
+rebuild_server_with_admin_pass['response_body']['properties']['server'][
+    'required'].append('locked_reason')
+
+update_server = copy.deepcopy(servers271.update_server)
+update_server['response_body']['properties']['server'][
+    'properties'].update({'locked_reason': locked_reason})
+update_server['response_body']['properties']['server'][
+    'required'].append('locked_reason')
+
+get_server = copy.deepcopy(servers271.get_server)
+get_server['response_body']['properties']['server'][
+    'properties'].update({'locked_reason': locked_reason})
+get_server['response_body']['properties']['server'][
+    'required'].append('locked_reason')
+
+list_servers_detail = copy.deepcopy(servers271.list_servers_detail)
+list_servers_detail['response_body']['properties']['servers']['items'][
+    'properties'].update({'locked_reason': locked_reason})
+list_servers_detail['response_body']['properties']['servers']['items'][
+    'required'].append('locked_reason')
+
+# NOTE(lajoskatona): Below are the unchanged schema in this microversion. We
+# need to keep this schema in this file to have the generic way to select the
+# right schema based on self.schema_versions_info mapping in service client.
+# ****** Schemas unchanged since microversion 2.71 ***
+list_servers = copy.deepcopy(servers271.list_servers)
+show_server_diagnostics = copy.deepcopy(servers271.show_server_diagnostics)
+get_remote_consoles = copy.deepcopy(servers271.get_remote_consoles)
+list_tags = copy.deepcopy(servers271.list_tags)
+update_all_tags = copy.deepcopy(servers271.update_all_tags)
+delete_all_tags = copy.deepcopy(servers271.delete_all_tags)
+check_tag_existence = copy.deepcopy(servers271.check_tag_existence)
+update_tag = copy.deepcopy(servers271.update_tag)
+delete_tag = copy.deepcopy(servers271.delete_tag)
diff --git a/tempest/lib/services/compute/servers_client.py b/tempest/lib/services/compute/servers_client.py
index a687137..cbf7a8c 100644
--- a/tempest/lib/services/compute/servers_client.py
+++ b/tempest/lib/services/compute/servers_client.py
@@ -35,6 +35,7 @@
 from tempest.lib.api_schema.response.compute.v2_63 import servers as schemav263
 from tempest.lib.api_schema.response.compute.v2_70 import servers as schemav270
 from tempest.lib.api_schema.response.compute.v2_71 import servers as schemav271
+from tempest.lib.api_schema.response.compute.v2_73 import servers as schemav273
 from tempest.lib.api_schema.response.compute.v2_8 import servers as schemav28
 from tempest.lib.api_schema.response.compute.v2_9 import servers as schemav29
 from tempest.lib.common import rest_client
@@ -59,7 +60,8 @@
         {'min': '2.57', 'max': '2.62', 'schema': schemav257},
         {'min': '2.63', 'max': '2.69', 'schema': schemav263},
         {'min': '2.70', 'max': '2.70', 'schema': schemav270},
-        {'min': '2.71', 'max': None, 'schema': schemav271}]
+        {'min': '2.71', 'max': '2.72', 'schema': schemav271},
+        {'min': '2.73', 'max': None, 'schema': schemav273}]
 
     def __init__(self, auth_provider, service, region,
                  enable_instance_password=True, **kwargs):
diff --git a/tempest/test_discover/plugins.py b/tempest/test_discover/plugins.py
index 7a037eb..b20b60e 100644
--- a/tempest/test_discover/plugins.py
+++ b/tempest/test_discover/plugins.py
@@ -194,11 +194,14 @@
     def get_plugin_load_tests_tuple(self):
         load_tests_dict = {}
         for plug in self.ext_plugins:
+            LOG.info('Loading tests from Tempest plugin: %s', plug.name)
             load_tests_dict[plug.name] = plug.obj.load_tests()
         return load_tests_dict
 
     def register_plugin_opts(self, conf):
         for plug in self.ext_plugins:
+            LOG.info('Register additional config options from Tempest '
+                     'plugin: %s', plug.name)
             try:
                 plug.obj.register_opts(conf)
             except Exception:
@@ -209,6 +212,9 @@
         plugin_options = []
         for plug in self.ext_plugins:
             opt_list = plug.obj.get_opt_lists()
+            LOG.info('List additional config options registered by '
+                     'Tempest plugin: %s', plug.name)
+
             if opt_list:
                 plugin_options.extend(opt_list)
         return plugin_options
diff --git a/tempest/tests/cmd/test_run.py b/tempest/tests/cmd/test_run.py
index 8997a4c..e9bbcc2 100644
--- a/tempest/tests/cmd/test_run.py
+++ b/tempest/tests/cmd/test_run.py
@@ -153,6 +153,15 @@
             result = ["b\'" + x + "\'" for x in result]
         self.assertEqual(result, tests)
 
+    def test_tempest_run_with_worker_file(self):
+        fd, path = tempfile.mkstemp()
+        self.addCleanup(os.remove, path)
+        worker_file = os.fdopen(fd, 'wb', 0)
+        self.addCleanup(worker_file.close)
+        worker_file.write(
+            '- worker:\n  - passing\n  concurrency: 3'.encode('utf-8'))
+        self.assertRunExit(['tempest', 'run', '--worker-file=%s' % path], 0)
+
     def test_tempest_run_with_whitelist(self):
         fd, path = tempfile.mkstemp()
         self.addCleanup(os.remove, path)