diff --git a/ironic_tempest_plugin/common/utils.py b/ironic_tempest_plugin/common/utils.py index 67c4922f..91f2001b 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 @@ def get_node(client, node_id=None, instance_uuid=None): :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 494c7800..c588a707 100644 --- a/ironic_tempest_plugin/services/baremetal/base.py +++ b/ironic_tempest_plugin/services/baremetal/base.py @@ -154,10 +154,14 @@ class BaremetalClient(rest_client.RestClient): 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 @@ class BaremetalClient(rest_client.RestClient): 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 550128a2..c9f52ae7 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 @@ class BaremetalClient(base.BaremetalClient): 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 @@ class BaremetalClient(base.BaremetalClient): 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 b4a8169a..763496a5 100644 --- a/ironic_tempest_plugin/tests/scenario/baremetal_manager.py +++ b/ironic_tempest_plugin/tests/scenario/baremetal_manager.py @@ -119,8 +119,9 @@ class BaremetalScenarioTest(manager.ScenarioTest): 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 47fc07e7..f78155d7 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 @@ class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest): * 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 @@ class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest): 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 @@ class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest): 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 @@ class BaremetalBasicOps(baremetal_manager.BaremetalScenarioTest): 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)