diff --git a/ironic_tempest_plugin/exceptions.py b/ironic_tempest_plugin/exceptions.py new file mode 100644 index 00000000..7791c40f --- /dev/null +++ b/ironic_tempest_plugin/exceptions.py @@ -0,0 +1,25 @@ +# 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. + +from tempest import exceptions + + +class IntrospectionFailed(exceptions.TempestException): + message = "Introspection failed" + + +class IntrospectionTimeout(exceptions.TempestException): + message = "Introspection time out" + + +class HypervisorUpdateTimeout(exceptions.TempestException): + message = "Hypervisor stats update time out" diff --git a/ironic_tempest_plugin/rules/basic_ops_rule.json b/ironic_tempest_plugin/rules/basic_ops_rule.json new file mode 100644 index 00000000..f1cfb0b2 --- /dev/null +++ b/ironic_tempest_plugin/rules/basic_ops_rule.json @@ -0,0 +1,25 @@ +[ + { + "description": "Successful Rule", + "conditions": [ + {"op": "ge", "field": "memory_mb", "value": 256}, + {"op": "ge", "field": "local_gb", "value": 1} + ], + "actions": [ + {"action": "set-attribute", "path": "/extra/rule_success", + "value": "yes"} + ] + }, + { + "description": "Failing Rule", + "conditions": [ + {"op": "lt", "field": "memory_mb", "value": 42}, + {"op": "eq", "field": "local_gb", "value": 0} + ], + "actions": [ + {"action": "set-attribute", "path": "/extra/rule_success", + "value": "no"}, + {"action": "fail", "message": "This rule should not have run"} + ] + } +] diff --git a/ironic_tempest_plugin/services/introspection_client.py b/ironic_tempest_plugin/services/introspection_client.py new file mode 100644 index 00000000..346e06c6 --- /dev/null +++ b/ironic_tempest_plugin/services/introspection_client.py @@ -0,0 +1,70 @@ +# 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 json + +from tempest import clients +from tempest.common import credentials_factory as common_creds +from tempest import config +from tempest.services.baremetal import base + + +CONF = config.CONF +ADMIN_CREDS = common_creds.get_configured_admin_credentials() + + +class Manager(clients.Manager): + def __init__(self, + credentials=ADMIN_CREDS, + service=None, + api_microversions=None): + super(Manager, self).__init__(credentials, service) + self.introspection_client = BaremetalIntrospectionClient( + self.auth_provider, + CONF.baremetal_introspection.catalog_type, + CONF.identity.region, + endpoint_type=CONF.baremetal_introspection.endpoint_type, + **self.default_params_with_timeout_values) + + +class BaremetalIntrospectionClient(base.BaremetalClient): + """Base Tempest REST client for Ironic Inspector API v1.""" + version = '1' + uri_prefix = 'v1' + + @base.handle_errors + def purge_rules(self): + """Purge all existing rules.""" + return self._delete_request('rules', uuid=None) + + @base.handle_errors + def import_rule(self, rule_path): + """Import introspection rules from a json file.""" + with open(rule_path, 'r') as fp: + rules = json.load(fp) + if not isinstance(rules, list): + rules = [rules] + + for rule in rules: + self._create_request('rules', rule) + + @base.handle_errors + def get_status(self, uuid): + """Get introspection status for a node.""" + return self._show_request('introspection', uuid=uuid) + + @base.handle_errors + def get_data(self, uuid): + """Get introspection data for a node.""" + return self._show_request('introspection', uuid=uuid, + uri='/%s/introspection/%s/data' % + (self.uri_prefix, uuid)) diff --git a/ironic_tempest_plugin/tests/manager.py b/ironic_tempest_plugin/tests/manager.py new file mode 100644 index 00000000..dd2a18eb --- /dev/null +++ b/ironic_tempest_plugin/tests/manager.py @@ -0,0 +1,140 @@ +# 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 os +import time + +from tempest import config + +from ironic_inspector.test.inspector_tempest_plugin import exceptions +from ironic_inspector.test.inspector_tempest_plugin.services import \ + introspection_client +from ironic_tempest_plugin.tests.scenario.baremetal_manager import \ + BaremetalScenarioTest + + +CONF = config.CONF + + +class InspectorScenarioTest(BaremetalScenarioTest): + """Provide harness to do Inspector scenario tests.""" + + credentials = ['primary', 'admin'] + + @classmethod + def setup_clients(cls): + super(InspectorScenarioTest, cls).setup_clients() + inspector_manager = introspection_client.Manager() + cls.introspection_client = inspector_manager.introspection_client + + def setUp(self): + super(InspectorScenarioTest, self).setUp() + self.flavor = self.baremetal_flavor() + + def item_filter(self, list_method, show_method, + filter=lambda item: True, items=None): + if items is None: + items = [show_method(item['uuid']) for item in + list_method()] + return [item for item in items if filter(item)] + + def node_list(self): + return self.baremetal_client.list_nodes()[1]['nodes'] + + def node_update(self, uuid, patch): + return self.baremetal_client.update_node(uuid, **patch) + + def node_show(self, uuid): + return self.baremetal_client.show_node(uuid)[1] + + def node_filter(self, filter=lambda node: True, nodes=None): + return self.item_filter(self.node_list, self.node_show, + filter=filter, items=nodes) + + def hypervisor_stats(self): + return (self.admin_manager.hypervisor_client. + show_hypervisor_statistics()) + + def server_show(self, uuid): + self.servers_client.show_server(uuid) + + def rule_purge(self): + self.introspection_client.purge_rules() + + def rule_import(self, rule_path): + self.introspection_client.import_rule(rule_path) + + def introspection_status(self, uuid): + return self.introspection_client.get_status(uuid)[1] + + def introspection_data(self, uuid): + return self.introspection_client.get_data(uuid)[1] + + def baremetal_flavor(self): + flavor_id = CONF.compute.flavor_ref + flavor = self.flavors_client.show_flavor(flavor_id)['flavor'] + flavor['properties'] = self.flavors_client.list_flavor_extra_specs( + flavor_id)['extra_specs'] + return flavor + + def get_rule_path(self, rule_file): + base_path = os.path.split( + os.path.dirname(os.path.abspath(__file__)))[0] + base_path = os.path.split(base_path)[0] + return os.path.join(base_path, "inspector_tempest_plugin", + "rules", rule_file) + + # TODO(aarefiev): switch to call_until_true + def wait_for_introspection_finished(self, node_ids): + """Waits for introspection of baremetal nodes to finish. + + """ + start = int(time.time()) + not_introspected = {node_id for node_id in node_ids} + + while not_introspected: + time.sleep(CONF.baremetal_introspection.introspection_sleep) + for node_id in node_ids: + status = self.introspection_status(node_id) + if status['finished']: + if status['error']: + message = ('Node %(node_id)s introspection failed ' + 'with %(error)s.' % + {'node_id': node_id, + 'error': status['error']}) + raise exceptions.IntrospectionFailed(message) + not_introspected = not_introspected - {node_id} + + if (int(time.time()) - start >= + CONF.baremetal_introspection.introspection_timeout): + message = ('Introspection timed out for nodes: %s' % + not_introspected) + raise exceptions.IntrospectionTimeout(message) + + def wait_for_nova_aware_of_bvms(self): + start = int(time.time()) + while True: + time.sleep(CONF.baremetal_introspection.hypervisor_update_sleep) + stats = self.hypervisor_stats() + expected_cpus = self.baremetal_flavor()['vcpus'] + if int(stats['hypervisor_statistics']['vcpus']) >= expected_cpus: + break + + timeout = CONF.baremetal_introspection.hypervisor_update_timeout + if (int(time.time()) - start >= timeout): + message = ( + 'Timeout while waiting for nova hypervisor-stats: ' + '%(stats)s required time (%(timeout)s s).' % + {'stats': stats, + 'timeout': timeout}) + raise exceptions.HypervisorUpdateTimeout(message) diff --git a/ironic_tempest_plugin/tests/test_basic.py b/ironic_tempest_plugin/tests/test_basic.py new file mode 100644 index 00000000..6830b78a --- /dev/null +++ b/ironic_tempest_plugin/tests/test_basic.py @@ -0,0 +1,149 @@ +# 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 tempest + +from tempest.config import CONF +from tempest import test # noqa + +from ironic_inspector.test.inspector_tempest_plugin.tests import manager +from ironic_tempest_plugin.tests.api.admin.api_microversion_fixture import \ + APIMicroversionFixture as IronicMicroversionFixture +from ironic_tempest_plugin.tests.scenario.baremetal_manager import \ + BaremetalProvisionStates +from tempest.lib.common.api_version_utils import LATEST_MICROVERSION + + +class InspectorBasicTest(manager.InspectorScenarioTest): + wait_provisioning_state_interval = 15 + + def node_cleanup(self, node_id): + if (self.node_show(node_id)['provision_state'] == + BaremetalProvisionStates.AVAILABLE): + return + try: + self.baremetal_client.set_node_provision_state(node_id, 'provide') + except tempest.lib.exceptions.RestClientException: + # maybe node already cleaning or available + pass + + self.wait_provisioning_state( + node_id, [BaremetalProvisionStates.AVAILABLE, + BaremetalProvisionStates.NOSTATE], + timeout=CONF.baremetal.unprovision_timeout, + interval=self.wait_provisioning_state_interval) + + def introspect_node(self, node_id): + # in case there are properties remove those + patch = {('properties/%s' % key): None for key in + self.node_show(node_id)['properties']} + # reset any previous rule result + patch['extra/rule_success'] = None + self.node_update(node_id, patch) + + self.baremetal_client.set_node_provision_state(node_id, 'manage') + self.baremetal_client.set_node_provision_state(node_id, 'inspect') + self.addCleanup(self.node_cleanup, node_id) + + def setUp(self): + super(InspectorBasicTest, self).setUp() + # we rely on the 'available' provision_state; using latest + # microversion + self.useFixture(IronicMicroversionFixture(LATEST_MICROVERSION)) + # avoid testing nodes that aren't available + self.node_ids = {node['uuid'] for node in + self.node_filter(filter=lambda node: + node['provision_state'] == + BaremetalProvisionStates.AVAILABLE)} + if not self.node_ids: + self.skipTest('no available nodes detected') + self.rule_purge() + + def verify_node_introspection_data(self, node): + self.assertEqual('yes', node['extra']['rule_success']) + data = self.introspection_data(node['uuid']) + self.assertEqual(data['cpu_arch'], + self.flavor['properties']['cpu_arch']) + self.assertEqual(int(data['memory_mb']), + int(self.flavor['ram'])) + self.assertEqual(int(data['cpus']), int(self.flavor['vcpus'])) + + def verify_node_flavor(self, node): + expected_cpus = self.flavor['vcpus'] + expected_memory_mb = self.flavor['ram'] + expected_cpu_arch = self.flavor['properties']['cpu_arch'] + disk_size = self.flavor['disk'] + ephemeral_size = self.flavor['OS-FLV-EXT-DATA:ephemeral'] + expected_local_gb = disk_size + ephemeral_size + + self.assertEqual(expected_cpus, + int(node['properties']['cpus'])) + self.assertEqual(expected_memory_mb, + int(node['properties']['memory_mb'])) + self.assertEqual(expected_local_gb, + int(node['properties']['local_gb'])) + self.assertEqual(expected_cpu_arch, + node['properties']['cpu_arch']) + + @test.idempotent_id('03bf7990-bee0-4dd7-bf74-b97ad7b52a4b') + @test.services('baremetal', 'compute', 'image', + 'network', 'object_storage') + def test_baremetal_introspection(self): + """This smoke test case follows this basic set of operations: + + * Fetches expected properties from baremetal flavor + * Removes all properties from nodes + * Sets nodes to manageable state + * Imports introspection rule basic_ops_rule.json + * Inspects nodes + * Verifies all properties are inspected + * Verifies introspection data + * Sets node to available state + * Creates a keypair + * Boots an instance using the keypair + * Deletes the instance + + """ + # prepare introspection rule + rule_path = self.get_rule_path("basic_ops_rule.json") + self.rule_import(rule_path) + self.addCleanup(self.rule_purge) + + for node_id in self.node_ids: + self.introspect_node(node_id) + + # settle down introspection + self.wait_for_introspection_finished(self.node_ids) + for node_id in self.node_ids: + self.wait_provisioning_state( + node_id, 'manageable', + timeout=CONF.baremetal_introspection.ironic_sync_timeout, + interval=self.wait_provisioning_state_interval) + + for node_id in self.node_ids: + node = self.node_show(node_id) + self.verify_node_introspection_data(node) + self.verify_node_flavor(node) + + for node_id in self.node_ids: + self.baremetal_client.set_node_provision_state(node_id, 'provide') + + for node_id in self.node_ids: + self.wait_provisioning_state( + node_id, BaremetalProvisionStates.AVAILABLE, + timeout=CONF.baremetal.active_timeout, + interval=self.wait_provisioning_state_interval) + + self.wait_for_nova_aware_of_bvms() + self.add_keypair() + self.boot_instance() + self.terminate_instance()