Validate scheduling fields in basic ops scenario

Currently there is no validation of node scheduling fields - resource
class and traits - in the scenario tests. This change adds validation of
these fields to the bare metal basic ops test.

We query the flavor used to boot the instance, and extract all requested
resources and traits from extra_specs. These are matched against the
resource class and traits set on the bare metal node that was scheduled.

Change-Id: I9ddc895ead61cf02c6967ead094d061cb7f558d8
Depends-On: https://review.openstack.org/545370
Related-Bug: #1722194
diff --git a/ironic_tempest_plugin/common/utils.py b/ironic_tempest_plugin/common/utils.py
index 67c4922..91f2001 100644
--- a/ironic_tempest_plugin/common/utils.py
+++ b/ironic_tempest_plugin/common/utils.py
@@ -11,7 +11,7 @@
 #    under the License.
 
 
-def get_node(client, node_id=None, instance_uuid=None):
+def get_node(client, node_id=None, instance_uuid=None, api_version=None):
     """Get a node by its identifier or instance UUID.
 
     If both node_id and instance_uuid specified, node_id will be used.
@@ -19,15 +19,17 @@
     :param client: an instance of tempest plugin BaremetalClient.
     :param node_id: identifier (UUID or name) of the node.
     :param instance_uuid: UUID of the instance.
+    :param api_version: Ironic API version to use.
     :returns: the requested node.
     :raises: AssertionError, if neither node_id nor instance_uuid was provided
     """
     assert node_id or instance_uuid, ('Either node or instance identifier '
                                       'has to be provided.')
     if node_id:
-        _, body = client.show_node(node_id)
+        _, body = client.show_node(node_id, api_version=api_version)
         return body
     elif instance_uuid:
-        _, body = client.show_node_by_instance_uuid(instance_uuid)
+        _, body = client.show_node_by_instance_uuid(instance_uuid,
+                                                    api_version=api_version)
         if body['nodes']:
             return body['nodes'][0]
diff --git a/ironic_tempest_plugin/services/baremetal/base.py b/ironic_tempest_plugin/services/baremetal/base.py
index 494c780..c588a70 100644
--- a/ironic_tempest_plugin/services/baremetal/base.py
+++ b/ironic_tempest_plugin/services/baremetal/base.py
@@ -154,10 +154,14 @@
                       resource,
                       uuid=None,
                       permanent=False,
+                      headers=None,
+                      extra_headers=False,
                       **kwargs):
         """Gets a specific object of the specified type.
 
         :param uuid: Unique identifier of the object in UUID format.
+        :param headers: List of headers to use in request.
+        :param extra_headers: Specify whether to use headers.
         :returns: Serialized object as a dictionary.
 
         """
@@ -165,7 +169,8 @@
             uri = kwargs['uri']
         else:
             uri = self._get_uri(resource, uuid=uuid, permanent=permanent)
-        resp, body = self.get(uri)
+        resp, body = self.get(uri, headers=headers,
+                              extra_headers=extra_headers)
         self.expected_success(http_client.OK, resp.status)
 
         return resp, self.deserialize(body)
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 550128a..c9f52ae 100644
--- a/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
+++ b/ironic_tempest_plugin/services/baremetal/v1/json/baremetal_client.py
@@ -20,6 +20,24 @@
     version = '1'
     uri_prefix = 'v1'
 
+    @staticmethod
+    def _get_headers(api_version):
+        """Return headers for a request.
+
+        Currently supports a header specifying the API version to use.
+
+        :param api_version: Ironic API version to use.
+        :return: a 2-tuple of (extra_headers, headers), where 'extra_headers'
+            is whether to use headers, and 'headers' is a list of headers to
+            use in the request.
+        """
+        extra_headers = False
+        headers = None
+        if api_version is not None:
+            extra_headers = True
+            headers = {'x-openstack-ironic-api-version': api_version}
+        return extra_headers, headers
+
     @base.handle_errors
     def list_nodes(self, **kwargs):
         """List all existing nodes."""
@@ -81,28 +99,33 @@
         return self._list_request('drivers')
 
     @base.handle_errors
-    def show_node(self, uuid):
+    def show_node(self, uuid, api_version=None):
         """Gets a specific node.
 
         :param uuid: Unique identifier of the node in UUID format.
+        :param api_version: Ironic API version to use.
         :return: Serialized node as a dictionary.
 
         """
-        return self._show_request('nodes', uuid)
+        extra_headers, headers = self._get_headers(api_version)
+        return self._show_request('nodes', uuid, headers=headers,
+                                  extra_headers=extra_headers)
 
     @base.handle_errors
-    def show_node_by_instance_uuid(self, instance_uuid):
+    def show_node_by_instance_uuid(self, instance_uuid, api_version=None):
         """Gets a node associated with given instance uuid.
 
         :param instance_uuid: Unique identifier of the instance in UUID format.
+        :param api_version: Ironic API version to use.
         :return: Serialized node as a dictionary.
 
         """
         uri = '/nodes/detail?instance_uuid=%s' % instance_uuid
-
+        extra_headers, headers = self._get_headers(api_version)
         return self._show_request('nodes',
                                   uuid=None,
-                                  uri=uri)
+                                  uri=uri, headers=headers,
+                                  extra_headers=extra_headers)
 
     @base.handle_errors
     def show_chassis(self, uuid):
diff --git a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
index b4a8169..763496a 100644
--- a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
+++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py
@@ -119,8 +119,9 @@
                                                       instance_id)
 
     @classmethod
-    def get_node(cls, node_id=None, instance_id=None):
-        return utils.get_node(cls.baremetal_client, node_id, instance_id)
+    def get_node(cls, node_id=None, instance_id=None, api_version=None):
+        return utils.get_node(cls.baremetal_client, node_id, instance_id,
+                              api_version)
 
     def get_ports(self, node_uuid):
         ports = []
diff --git a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
index 47fc07e..f78155d 100644
--- a/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
+++ b/ironic_tempest_plugin/tests/scenario/test_baremetal_basic_ops.py
@@ -35,6 +35,7 @@
         * Monitors the associated Ironic node for power and
           expected state transitions
         * Validates Ironic node's port data has been properly updated
+        * Validates Ironic node's resource class and traits have been honoured
         * Verifies SSH connectivity using created keypair via fixed IP
         * Associates a floating ip
         * Verifies SSH connectivity using created keypair via floating IP
@@ -44,6 +45,16 @@
           expected state transitions
     """
 
+    @staticmethod
+    def _is_version_supported(version):
+        """Return whether an API microversion is supported."""
+        min_version = api_version_request.APIVersionRequest(
+            CONF.baremetal.min_microversion)
+        max_version = api_version_request.APIVersionRequest(
+            CONF.baremetal.max_microversion)
+        version = api_version_request.APIVersionRequest(version)
+        return min_version <= version <= max_version
+
     def rebuild_instance(self, preserve_ephemeral=False):
         self.rebuild_server(server_id=self.instance['id'],
                             preserve_ephemeral=preserve_ephemeral,
@@ -105,9 +116,7 @@
         vifs = []
         # TODO(vsaienko) switch to get_node_vifs() when all stable releases
         # supports Ironic API 1.28
-        if (api_version_request.APIVersionRequest(
-            CONF.baremetal.max_microversion) >=
-                api_version_request.APIVersionRequest('1.28')):
+        if self._is_version_supported('1.28'):
             vifs = self.get_node_vifs(node_uuid)
         else:
             for port in self.get_ports(self.node['uuid']):
@@ -124,12 +133,65 @@
             self.assertEqual(n_port['device_id'], self.instance['id'])
             self.assertIn(n_port['mac_address'], ir_ports_addresses)
 
+    def validate_scheduling(self):
+        """Validate scheduling attributes of the node against the flavor.
+
+        Validates the resource class and traits requested by the flavor against
+        those set on the node. Does not assume that resource classes and traits
+        are in use.
+        """
+        # Try to get a node with resource class (1.21) and traits (1.37).
+        # TODO(mgoddard): Remove this when all stable releases support these
+        # API versions.
+        for version in ('1.37', '1.21'):
+            if self._is_version_supported(version):
+                node = self.get_node(instance_id=self.instance['id'],
+                                     api_version=version)
+                break
+        else:
+            # Neither API is supported - cannot test.
+            LOG.warning("Cannot validate resource class and trait based "
+                        "scheduling as these require API version 1.21 and "
+                        "1.37 respectively")
+            return
+
+        f_id = self.instance['flavor']['id']
+        extra_specs = self.flavors_client.list_flavor_extra_specs(f_id)
+        extra_specs = extra_specs['extra_specs']
+
+        # Pull the requested resource class and traits from the flavor.
+        resource_class = None
+        traits = set()
+        for key, value in extra_specs.items():
+            if key.startswith('resources:CUSTOM_') and value == '1':
+                resource_class = key.partition(':')[2]
+            if key.startswith('trait:') and value == 'required':
+                trait = key.partition(':')[2]
+                traits.add(trait)
+
+        # Validate requested resource class and traits against the node.
+        if resource_class is not None:
+            # The resource class in ironic may be lower case, and must omit the
+            # CUSTOM_ prefix. Normalise it.
+            node_resource_class = node['resource_class']
+            node_resource_class = node_resource_class.upper()
+            node_resource_class = 'CUSTOM_' + node_resource_class
+            self.assertEqual(resource_class, node_resource_class)
+
+        if 'traits' in node and traits:
+            self.assertIn('traits', node['instance_info'])
+            # All flavor traits should be added as instance traits.
+            self.assertEqual(traits, set(node['instance_info']['traits']))
+            # Flavor traits should be a subset of node traits.
+            self.assertTrue(traits.issubset(set(node['traits'])))
+
     @decorators.idempotent_id('549173a5-38ec-42bb-b0e2-c8b9f4a08943')
     @utils.services('compute', 'image', 'network')
     def test_baremetal_server_ops(self):
         self.add_keypair()
         self.instance, self.node = self.boot_instance()
         self.validate_ports()
+        self.validate_scheduling()
         ip_address = self.get_server_ip(self.instance)
         self.get_remote_client(ip_address).validate_authentication()
         vm_client = self.get_remote_client(ip_address)