Add support for API microversions in Tempest tests

This adds support for testing Ironic API microversions, specified
as an additional 'X-OpenStack-Ironic-API-Version' header. This change
also adds tests for Ironic API /v1/nodes/(node_ident)/states/*
endpoint for microversions that were changing state machine.

Co-Authored-By: Vladyslav Drok <vdrok@mirantis.com>
Change-Id: Ibf0c73aa6795aaa52e945fd6baa821de20a599e7
diff --git a/ironic_tempest_plugin/clients.py b/ironic_tempest_plugin/clients.py
index 70ce134..2cb7c73 100644
--- a/ironic_tempest_plugin/clients.py
+++ b/ironic_tempest_plugin/clients.py
@@ -28,8 +28,13 @@
 class Manager(clients.Manager):
     def __init__(self,
                  credentials=ADMIN_CREDS,
-                 service=None,
-                 api_microversions=None):
+                 service=None):
+        """Initialization of Manager class.
+
+        Setup service client and make it available for test cases.
+        :param credentials: type Credentials or TestResources
+        :param service: service name
+        """
         super(Manager, self).__init__(credentials, service)
         self.baremetal_client = BaremetalClient(
             self.auth_provider,
diff --git a/ironic_tempest_plugin/config.py b/ironic_tempest_plugin/config.py
index 1f9ba51..36d59df 100644
--- a/ironic_tempest_plugin/config.py
+++ b/ironic_tempest_plugin/config.py
@@ -66,4 +66,20 @@
     #            help="Timeout for unprovisioning an Ironic node. "
     #                 "Takes longer since Kilo as Ironic performs an extra "
     #                 "step in Node cleaning.")
+    cfg.StrOpt('min_microversion',
+               default=None,
+               help="Lower version of the test target microversion range. "
+                    "The format is 'X.Y', where 'X' and 'Y' are int values. "
+                    "Tempest selects tests based on the range between "
+                    "min_microversion and max_microversion. "
+                    "If both values are None, Tempest avoids tests which "
+                    "require a microversion."),
+    cfg.StrOpt('max_microversion',
+               default='latest',
+               help="Upper version of the test target microversion range. "
+                    "The format is 'X.Y', where 'X' and 'Y' are int values. "
+                    "Tempest selects tests based on the range between "
+                    "min_microversion and max_microversion. "
+                    "If both values are None, Tempest avoids tests which "
+                    "require a microversion."),
 ]
diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py
index edb9ecb..b7a9c32 100644
--- a/ironic_tempest_plugin/services/baremetal/base.py
+++ b/ironic_tempest_plugin/services/baremetal/base.py
@@ -15,8 +15,11 @@
 from oslo_serialization import jsonutils as json
 import six
 from six.moves.urllib import parse as urllib
+from tempest.lib.common import api_version_utils
 from tempest.lib.common import rest_client
 
+BAREMETAL_MICROVERSION = None
+
 
 def handle_errors(f):
     """A decorator that allows to ignore certain types of errors."""
@@ -41,8 +44,27 @@
 class BaremetalClient(rest_client.RestClient):
     """Base Tempest REST client for Ironic API."""
 
+    api_microversion_header_name = 'X-OpenStack-Ironic-API-Version'
     uri_prefix = ''
 
+    def get_headers(self):
+        headers = super(BaremetalClient, self).get_headers()
+        if BAREMETAL_MICROVERSION:
+            headers[self.api_microversion_header_name] = BAREMETAL_MICROVERSION
+        return headers
+
+    def request(self, method, url, extra_headers=False, headers=None,
+                body=None):
+        resp, resp_body = super(BaremetalClient, self).request(
+            method, url, extra_headers, headers, body)
+        if (BAREMETAL_MICROVERSION and
+            BAREMETAL_MICROVERSION != api_version_utils.LATEST_MICROVERSION):
+            api_version_utils.assert_version_header_matches_request(
+                self.api_microversion_header_name,
+                BAREMETAL_MICROVERSION,
+                resp)
+        return resp, resp_body
+
     def serialize(self, object_dict):
         """Serialize an Ironic object."""
 
diff --git a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
index cea449a..1863fc2 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -265,6 +265,29 @@
                                  target)
 
     @base.handle_errors
+    def set_node_provision_state(self, node_uuid, state, configdrive=None):
+        """Set provision state of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :state: desired state to set
+                (active/rebuild/deleted/inspect/manage/provide).
+        :config_drive: A gzipped, base64-encoded configuration drive string.
+        """
+        data = {'target': state, 'configdrive': configdrive}
+        return self._put_request('nodes/%s/states/provision' % node_uuid,
+                                 data)
+
+    @base.handle_errors
+    def set_node_raid_config(self, node_uuid, target_raid_config):
+        """Set raid config of the specified node.
+
+        :param node_uuid: The unique identifier of the node.
+        :target_raid_config: desired RAID configuration of the node.
+        """
+        return self._put_request('nodes/%s/states/raid' % node_uuid,
+                                 target_raid_config)
+
+    @base.handle_errors
     def validate_driver_interface(self, node_uuid):
         """Get all driver interfaces of a specific node.
 
diff --git a/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py b/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py
new file mode 100644
index 0000000..9dd643c
--- /dev/null
+++ b/ironic_tempest_plugin/tests/api/admin/api_microversion_fixture.py
@@ -0,0 +1,29 @@
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import fixtures
+
+from ironic_tempest_plugin.services.baremetal import base
+
+
+class APIMicroversionFixture(fixtures.Fixture):
+
+    def __init__(self, baremetal_microversion):
+        self.baremetal_microversion = baremetal_microversion
+
+    def _setUp(self):
+        super(APIMicroversionFixture, self)._setUp()
+        base.BAREMETAL_MICROVERSION = self.baremetal_microversion
+        self.addCleanup(self._reset_compute_microversion)
+
+    def _reset_compute_microversion(self):
+        base.BAREMETAL_MICROVERSION = None
diff --git a/ironic_tempest_plugin/tests/api/admin/base.py b/ironic_tempest_plugin/tests/api/admin/base.py
index 61270c7..124fc33 100644
--- a/ironic_tempest_plugin/tests/api/admin/base.py
+++ b/ironic_tempest_plugin/tests/api/admin/base.py
@@ -13,11 +13,13 @@
 import functools
 
 from tempest import config
+from tempest.lib.common import api_version_utils
 from tempest.lib.common.utils import data_utils
 from tempest.lib import exceptions as lib_exc
 from tempest import test
 
 from ironic_tempest_plugin import clients
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
 
 CONF = config.CONF
 
@@ -50,7 +52,8 @@
     return decorator
 
 
-class BaseBaremetalTest(test.BaseTestCase):
+class BaseBaremetalTest(api_version_utils.BaseMicroversionTest,
+                        test.BaseTestCase):
     """Base class for Baremetal API tests."""
 
     credentials = ['admin']
@@ -64,6 +67,23 @@
                         (cls.__name__, CONF.baremetal.driver))
             raise cls.skipException(skip_msg)
 
+        cfg_min_version = CONF.baremetal.min_microversion
+        cfg_max_version = CONF.baremetal.max_microversion
+        api_version_utils.check_skip_with_microversion(cls.min_microversion,
+                                                       cls.max_microversion,
+                                                       cfg_min_version,
+                                                       cfg_max_version)
+
+    @classmethod
+    def setup_credentials(cls):
+        cls.request_microversion = (
+            api_version_utils.select_request_microversion(
+                cls.min_microversion,
+                CONF.baremetal.min_microversion))
+        cls.services_microversion = {
+            CONF.baremetal.catalog_type: cls.request_microversion}
+        super(BaseBaremetalTest, cls).setup_credentials()
+
     @classmethod
     def setup_clients(cls):
         super(BaseBaremetalTest, cls).setup_clients()
@@ -72,9 +92,13 @@
     @classmethod
     def resource_setup(cls):
         super(BaseBaremetalTest, cls).resource_setup()
-
+        cls.request_microversion = (
+            api_version_utils.select_request_microversion(
+                cls.min_microversion,
+                CONF.baremetal.min_microversion))
         cls.driver = CONF.baremetal.driver
         cls.power_timeout = CONF.baremetal.power_timeout
+        cls.unprovision_timeout = CONF.baremetal.unprovision_timeout
         cls.created_objects = {}
         for resource in RESOURCE_TYPES:
             cls.created_objects[resource] = set()
@@ -92,6 +116,11 @@
         finally:
             super(BaseBaremetalTest, cls).resource_cleanup()
 
+    def setUp(self):
+        super(BaseBaremetalTest, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture(
+            self.request_microversion))
+
     @classmethod
     @creates('chassis')
     def create_chassis(cls, description=None, expect_errors=False):
diff --git a/ironic_tempest_plugin/tests/api/admin/test_nodestates.py b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py
index f418fcc..58ca016 100644
--- a/ironic_tempest_plugin/tests/api/admin/test_nodestates.py
+++ b/ironic_tempest_plugin/tests/api/admin/test_nodestates.py
@@ -16,17 +16,17 @@
 from tempest.lib import exceptions
 from tempest import test
 
+from ironic_tempest_plugin.tests.api.admin import api_microversion_fixture
 from ironic_tempest_plugin.tests.api.admin import base
 
 
-class TestNodeStates(base.BaseBaremetalTest):
-    """Tests for baremetal NodeStates."""
+class TestNodeStatesMixin(object):
+    """Mixin for for baremetal node states tests."""
 
     @classmethod
     def resource_setup(cls):
-        super(TestNodeStates, cls).resource_setup()
+        super(TestNodeStatesMixin, cls).resource_setup()
         _, cls.chassis = cls.create_chassis()
-        _, cls.node = cls.create_node(cls.chassis['uuid'])
 
     def _validate_power_state(self, node_uuid, power_state):
         # Validate that power state is set within timeout
@@ -42,11 +42,26 @@
                    'the required time: %s sec.' % self.power_timeout)
         raise exceptions.TimeoutException(message)
 
+    def _validate_provision_state(self, node_uuid, target_state):
+        # Validate that provision state is set within timeout
+        start = timeutils.utcnow()
+        while timeutils.delta_seconds(
+                start, timeutils.utcnow()) < self.unprovision_timeout:
+            _, node = self.client.show_node(node_uuid)
+            if node['provision_state'] == target_state:
+                return
+        message = ('Failed to set provision state %(state)s within '
+                   'the required time: %(timeout)s sec.',
+                   {'state': target_state,
+                    'timeout': self.unprovision_timeout})
+        raise exceptions.TimeoutException(message)
+
     @test.idempotent_id('cd8afa5e-3f57-4e43-8185-beb83d3c9015')
     def test_list_nodestates(self):
-        _, nodestates = self.client.list_nodestates(self.node['uuid'])
+        _, node = self.create_node(self.chassis['uuid'])
+        _, nodestates = self.client.list_nodestates(node['uuid'])
         for key in nodestates:
-            self.assertEqual(nodestates[key], self.node[key])
+            self.assertEqual(nodestates[key], node[key])
 
     @test.idempotent_id('fc5b9320-0c98-4e5a-8848-877fe5a0322c')
     def test_set_node_power_state(self):
@@ -57,3 +72,122 @@
             self.client.set_node_power_state(node['uuid'], state)
             # Check power state after state is set
             self._validate_power_state(node['uuid'], state)
+
+
+class TestNodeStatesV1_1(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    @test.idempotent_id('ccb8fca9-2ba0-480c-a037-34c3bd09dc74')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in NONE state by default until v1.1
+        self.assertEqual(None, node['provision_state'])
+        provision_states_list = ['active', 'deleted']
+        target_states_list = ['active', None]
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_2(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_2, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.2'))
+
+    @test.idempotent_id('9c414984-f3b6-4b3d-81da-93b60d4662fb')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        provision_states_list = ['active', 'deleted']
+        target_states_list = ['active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_4(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_4, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.4'))
+
+    @test.idempotent_id('3d606003-05ce-4b5a-964d-bdee382fafe9')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        # MANAGEABLE state and PROVIDE transition have been added in v1.4
+        provision_states_list = [
+            'manage', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_6(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_6, self).setUp()
+        self.useFixture(api_microversion_fixture.APIMicroversionFixture('1.6'))
+
+    @test.idempotent_id('6c9ce4a3-713b-4c76-91af-18c48d01f1bb')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in AVAILABLE state by default from v1.2 to v1.10
+        self.assertEqual('available', node['provision_state'])
+        # INSPECT* states have been added in v1.6
+        provision_states_list = [
+            'manage', 'inspect', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_11(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_11, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.11')
+        )
+
+    @test.idempotent_id('31f53828-b83d-40c7-98e5-843e28a1b6b9')
+    def test_set_node_provision_state(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        # Nodes appear in ENROLL state by default from v1.11
+        self.assertEqual('enroll', node['provision_state'])
+        provision_states_list = [
+            'manage', 'inspect', 'provide', 'active', 'deleted']
+        target_states_list = [
+            'manageable', 'manageable', 'available', 'active', 'available']
+        for (provision_state, target_state) in zip(provision_states_list,
+                                                   target_states_list):
+            self.client.set_node_provision_state(node['uuid'], provision_state)
+            self._validate_provision_state(node['uuid'], target_state)
+
+
+class TestNodeStatesV1_12(TestNodeStatesMixin, base.BaseBaremetalTest):
+
+    def setUp(self):
+        super(TestNodeStatesV1_12, self).setUp()
+        self.useFixture(
+            api_microversion_fixture.APIMicroversionFixture('1.12')
+        )
+
+    @test.idempotent_id('4427b1ca-8e79-4139-83d6-77dfac03e61e')
+    def test_set_node_raid_config(self):
+        _, node = self.create_node(self.chassis['uuid'])
+        target_raid_config = {'logical_disks': [{'size_gb': 100,
+                                                 'raid_level': '1'}]}
+        self.client.set_node_raid_config(node['uuid'], target_raid_config)
+        _, ret = self.client.show_node(node['uuid'])
+        self.assertEqual(target_raid_config, ret['target_raid_config'])