From 4a48a603f43dd1ed4583f75b7b2696d793fa3932 Mon Sep 17 00:00:00 2001 From: Adam Gandelman Date: Thu, 20 Mar 2014 18:23:18 -0700 Subject: [PATCH] Adds Ironic test_baremetal_basic_ops scenario test Adds an Ironic scenario test that validates a full instance boot using Ironic. In addition to verifying the Nova instance boots and has connectivity, it monitors power and state transitions on the Ironic side. It currently validates orchestration of the pxe_ssh driver but the goal would be to support other drivers, and test them conditionally based on the driver associated with the configured Ironic node. Change-Id: I7a98ab9c771fe17387dfb591df5a40d27194a5c8 --- etc/tempest.conf.sample | 22 ++- requirements.txt | 1 + tempest/clients.py | 47 ++++++ tempest/config.py | 20 ++- tempest/scenario/manager.py | 81 +++++++++- tempest/scenario/test_baremetal_basic_ops.py | 147 +++++++++++++++++++ tempest/test.py | 1 + 7 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 tempest/scenario/test_baremetal_basic_ops.py diff --git a/etc/tempest.conf.sample b/etc/tempest.conf.sample index 761a07748c..7cc211ed42 100644 --- a/etc/tempest.conf.sample +++ b/etc/tempest.conf.sample @@ -101,14 +101,32 @@ # Options defined in tempest.config # -# Catalog type of the baremetal provisioning service. (string +# Catalog type of the baremetal provisioning service (string # value) #catalog_type=baremetal +# Whether the Ironic nova-compute driver is enabled (boolean +# value) +#driver_enabled=false + # The endpoint type to use for the baremetal provisioning -# service. (string value) +# service (string value) #endpoint_type=publicURL +# Timeout for Ironic node to completely provision (integer +# value) +#active_timeout=300 + +# Timeout for association of Nova instance and Ironic node +# (integer value) +#association_timeout=10 + +# Timeout for Ironic power transitions. (integer value) +#power_timeout=20 + +# Timeout for unprovisioning an Ironic node. (integer value) +#unprovision_timeout=20 + [boto] diff --git a/requirements.txt b/requirements.txt index a18b09207e..3ab4ef93a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ python-novaclient>=2.17.0 python-neutronclient>=2.3.4,<3 python-cinderclient>=1.0.6 python-heatclient>=0.2.3 +python-ironicclient python-saharaclient>=0.6.0 python-swiftclient>=1.6 testresources>=0.2.4 diff --git a/tempest/clients.py b/tempest/clients.py index 7ebd98316f..ef160813da 100644 --- a/tempest/clients.py +++ b/tempest/clients.py @@ -17,6 +17,7 @@ import cinderclient.client import glanceclient import heatclient.client +import ironicclient.client import keystoneclient.exceptions import keystoneclient.v2_0.client import neutronclient.v2_0.client @@ -456,6 +457,7 @@ class OfficialClientManager(manager.Manager): NOVACLIENT_VERSION = '2' CINDERCLIENT_VERSION = '1' HEATCLIENT_VERSION = '1' + IRONICCLIENT_VERSION = '1' def __init__(self, username, password, tenant_name): # FIXME(andreaf) Auth provider for client_type 'official' is @@ -465,6 +467,7 @@ class OfficialClientManager(manager.Manager): # super cares for credentials validation super(OfficialClientManager, self).__init__( username=username, password=password, tenant_name=tenant_name) + self.baremetal_client = self._get_baremetal_client() self.compute_client = self._get_compute_client(username, password, tenant_name) @@ -485,6 +488,22 @@ class OfficialClientManager(manager.Manager): password, tenant_name) + def _get_roles(self): + keystone_admin = self._get_identity_client( + CONF.identity.admin_username, + CONF.identity.admin_password, + CONF.identity.admin_tenant_name) + + username = self.credentials['username'] + tenant_name = self.credentials['tenant_name'] + user_id = keystone_admin.users.find(name=username).id + tenant_id = keystone_admin.tenants.find(name=tenant_name).id + + roles = keystone_admin.roles.roles_for_user( + user=user_id, tenant=tenant_id) + + return [r.name for r in roles] + def _get_compute_client(self, username, password, tenant_name): # Novaclient will not execute operations for anyone but the # identified user, so a new client needs to be created for @@ -606,6 +625,34 @@ class OfficialClientManager(manager.Manager): auth_url=auth_url, insecure=dscv) + def _get_baremetal_client(self): + # ironic client is currently intended to by used by admin users + roles = self._get_roles() + if CONF.identity.admin_role not in roles: + return None + + auth_url = CONF.identity.uri + api_version = self.IRONICCLIENT_VERSION + insecure = CONF.identity.disable_ssl_certificate_validation + service_type = CONF.baremetal.catalog_type + endpoint_type = CONF.baremetal.endpoint_type + creds = { + 'os_username': self.credentials['username'], + 'os_password': self.credentials['password'], + 'os_tenant_name': self.credentials['tenant_name'] + } + + try: + return ironicclient.client.get_client( + api_version=api_version, + os_auth_url=auth_url, + insecure=insecure, + os_service_type=service_type, + os_endpoint_type=endpoint_type, + **creds) + except keystoneclient.exceptions.EndpointNotFound: + return None + def _get_network_client(self): # The intended configuration is for the network client to have # admin privileges and indicate for whom resources are being diff --git a/tempest/config.py b/tempest/config.py index b0945bb417..152c3d9ed1 100644 --- a/tempest/config.py +++ b/tempest/config.py @@ -818,13 +818,29 @@ baremetal_group = cfg.OptGroup(name='baremetal', BaremetalGroup = [ cfg.StrOpt('catalog_type', default='baremetal', - help="Catalog type of the baremetal provisioning service."), + help="Catalog type of the baremetal provisioning service"), + cfg.BoolOpt('driver_enabled', + default=False, + help="Whether the Ironic nova-compute driver is enabled"), cfg.StrOpt('endpoint_type', default='publicURL', choices=['public', 'admin', 'internal', 'publicURL', 'adminURL', 'internalURL'], help="The endpoint type to use for the baremetal provisioning " - "service."), + "service"), + cfg.IntOpt('active_timeout', + default=300, + help="Timeout for Ironic node to completely provision"), + cfg.IntOpt('association_timeout', + default=10, + help="Timeout for association of Nova instance and Ironic " + "node"), + cfg.IntOpt('power_timeout', + default=20, + help="Timeout for Ironic power transitions."), + cfg.IntOpt('unprovision_timeout', + default=20, + help="Timeout for unprovisioning an Ironic node.") ] cli_group = cfg.OptGroup(name='cli', title="cli Configuration Options") diff --git a/tempest/scenario/manager.py b/tempest/scenario/manager.py index f06a85071c..d7be534269 100644 --- a/tempest/scenario/manager.py +++ b/tempest/scenario/manager.py @@ -19,6 +19,7 @@ import os import six import subprocess +from ironicclient import exc as ironic_exceptions import netaddr from neutronclient.common import exceptions as exc from novaclient import exceptions as nova_exceptions @@ -71,6 +72,7 @@ class OfficialClientTest(tempest.test.BaseTestCase): username, password, tenant_name) cls.compute_client = cls.manager.compute_client cls.image_client = cls.manager.image_client + cls.baremetal_client = cls.manager.baremetal_client cls.identity_client = cls.manager.identity_client cls.network_client = cls.manager.network_client cls.volume_client = cls.manager.volume_client @@ -283,7 +285,7 @@ class OfficialClientTest(tempest.test.BaseTestCase): return rules def create_server(self, client=None, name=None, image=None, flavor=None, - create_kwargs={}): + wait=True, create_kwargs={}): if client is None: client = self.compute_client if name is None: @@ -318,7 +320,8 @@ class OfficialClientTest(tempest.test.BaseTestCase): server = client.servers.create(name, image, flavor, **create_kwargs) self.assertEqual(server.name, name) self.set_resource(name, server) - self.status_timeout(client.servers, server.id, 'ACTIVE') + if wait: + self.status_timeout(client.servers, server.id, 'ACTIVE') # The instance retrieved on creation is missing network # details, necessitating retrieval after it becomes active to # ensure correct details. @@ -439,6 +442,80 @@ class OfficialClientTest(tempest.test.BaseTestCase): LOG.debug("image:%s" % self.image) +class BaremetalScenarioTest(OfficialClientTest): + @classmethod + def setUpClass(cls): + super(BaremetalScenarioTest, cls).setUpClass() + + if (not CONF.service_available.ironic or + not CONF.baremetal.driver_enabled): + msg = 'Ironic not available or Ironic compute driver not enabled' + raise cls.skipException(msg) + + # use an admin client manager for baremetal client + username, password, tenant = cls.admin_credentials() + manager = clients.OfficialClientManager(username, password, tenant) + cls.baremetal_client = manager.baremetal_client + + # allow any issues obtaining the node list to raise early + cls.baremetal_client.node.list() + + def _node_state_timeout(self, node_id, state_attr, + target_states, timeout=10, interval=1): + if not isinstance(target_states, list): + target_states = [target_states] + + def check_state(): + node = self.get_node(node_id=node_id) + if getattr(node, state_attr) in target_states: + return True + return False + + if not tempest.test.call_until_true( + check_state, timeout, interval): + msg = ("Timed out waiting for node %s to reach %s state(s) %s" % + (node_id, state_attr, target_states)) + raise exceptions.TimeoutException(msg) + + def wait_provisioning_state(self, node_id, state, timeout): + self._node_state_timeout( + node_id=node_id, state_attr='provision_state', + target_states=state, timeout=timeout) + + def wait_power_state(self, node_id, state): + self._node_state_timeout( + node_id=node_id, state_attr='power_state', + target_states=state, timeout=CONF.baremetal.power_timeout) + + def wait_node(self, instance_id): + """Waits for a node to be associated with instance_id.""" + def _get_node(): + node = None + try: + node = self.get_node(instance_id=instance_id) + except ironic_exceptions.HTTPNotFound: + pass + return node is not None + + if not tempest.test.call_until_true( + _get_node, CONF.baremetal.association_timeout, 1): + msg = ('Timed out waiting to get Ironic node by instance id %s' + % instance_id) + raise exceptions.TimeoutException(msg) + + def get_node(self, node_id=None, instance_id=None): + if node_id: + return self.baremetal_client.node.get(node_id) + elif instance_id: + return self.baremetal_client.node.get_by_instance_uuid(instance_id) + + def get_ports(self, node_id): + ports = [] + for port in self.baremetal_client.node.list_ports(node_id): + ports.append(self.baremetal_client.port.get(port.uuid)) + return ports + + class NetworkScenarioTest(OfficialClientTest): """ Base class for network scenario tests diff --git a/tempest/scenario/test_baremetal_basic_ops.py b/tempest/scenario/test_baremetal_basic_ops.py new file mode 100644 index 0000000000..c53aa83118 --- /dev/null +++ b/tempest/scenario/test_baremetal_basic_ops.py @@ -0,0 +1,147 @@ +# +# Copyright 2014 Hewlett-Packard Development Company, L.P. +# +# 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 config +from tempest.openstack.common import log as logging +from tempest.scenario import manager +from tempest import test + +CONF = config.CONF + +LOG = logging.getLogger(__name__) + + +# power/provision states as of icehouse +class PowerStates(object): + """Possible power states of an Ironic node.""" + POWER_ON = 'power on' + POWER_OFF = 'power off' + REBOOT = 'rebooting' + SUSPEND = 'suspended' + + +class ProvisionStates(object): + """Possible provision states of an Ironic node.""" + NOSTATE = None + INIT = 'initializing' + ACTIVE = 'active' + BUILDING = 'building' + DEPLOYWAIT = 'wait call-back' + DEPLOYING = 'deploying' + DEPLOYFAIL = 'deploy failed' + DEPLOYDONE = 'deploy complete' + DELETING = 'deleting' + DELETED = 'deleted' + ERROR = 'error' + + +class BaremetalBasicOptsPXESSH(manager.BaremetalScenarioTest): + """ + This smoke test tests the pxe_ssh Ironic driver. It follows this basic + set of operations: + * Creates a keypair + * Boots an instance using the keypair + * Monitors the associated Ironic node for power and + expected state transitions + * Validates Ironic node's driver_info has been properly + updated + * Validates Ironic node's port data has been properly updated + * Verifies SSH connectivity using created keypair via fixed IP + * Associates a floating ip + * Verifies SSH connectivity using created keypair via floating IP + * Deletes instance + * Monitors the associated Ironic node for power and + expected state transitions + """ + def add_keypair(self): + self.keypair = self.create_keypair() + + def add_floating_ip(self): + floating_ip = self.compute_client.floating_ips.create() + self.instance.add_floating_ip(floating_ip) + return floating_ip.ip + + def verify_connectivity(self, ip=None): + if ip: + dest = self.get_remote_client(ip) + else: + dest = self.get_remote_client(self.instance) + dest.validate_authentication() + + def validate_driver_info(self): + f_id = self.instance.flavor['id'] + flavor_extra = self.compute_client.flavors.get(f_id).get_keys() + driver_info = self.node.driver_info + self.assertEqual(driver_info['pxe_deploy_kernel'], + flavor_extra['baremetal:deploy_kernel_id']) + self.assertEqual(driver_info['pxe_deploy_ramdisk'], + flavor_extra['baremetal:deploy_ramdisk_id']) + self.assertEqual(driver_info['pxe_image_source'], + self.instance.image['id']) + + def validate_ports(self): + for port in self.get_ports(self.node.uuid): + n_port_id = port.extra['vif_port_id'] + n_port = self.network_client.show_port(n_port_id)['port'] + self.assertEqual(n_port['device_id'], self.instance.id) + self.assertEqual(n_port['mac_address'], port.address) + + def boot_instance(self): + create_kwargs = { + 'key_name': self.keypair.id + } + self.instance = self.create_server( + wait=False, create_kwargs=create_kwargs) + + self.set_resource('instance', self.instance) + + self.wait_node(self.instance.id) + self.node = self.get_node(instance_id=self.instance.id) + + self.wait_power_state(self.node.uuid, PowerStates.POWER_ON) + + self.wait_provisioning_state( + self.node.uuid, + [ProvisionStates.DEPLOYWAIT, ProvisionStates.ACTIVE], + timeout=15) + + self.wait_provisioning_state(self.node.uuid, ProvisionStates.ACTIVE, + timeout=CONF.baremetal.active_timeout) + + self.status_timeout( + self.compute_client.servers, self.instance.id, 'ACTIVE') + + self.node = self.get_node(instance_id=self.instance.id) + self.instance = self.compute_client.servers.get(self.instance.id) + + def terminate_instance(self): + self.instance.delete() + self.remove_resource('instance') + self.wait_power_state(self.node.uuid, PowerStates.POWER_OFF) + self.wait_provisioning_state( + self.node.uuid, + ProvisionStates.NOSTATE, + timeout=CONF.baremetal.unprovision_timeout) + + @test.services('baremetal', 'compute', 'image', 'network') + def test_baremetal_server_ops(self): + self.add_keypair() + self.boot_instance() + self.validate_driver_info() + self.validate_ports() + self.verify_connectivity() + floating_ip = self.add_floating_ip() + self.verify_connectivity(ip=floating_ip) + self.terminate_instance() diff --git a/tempest/test.py b/tempest/test.py index abf42c09cf..0b79e3409b 100644 --- a/tempest/test.py +++ b/tempest/test.py @@ -93,6 +93,7 @@ def services(*args, **kwargs): service_list = { 'compute': CONF.service_available.nova, 'image': CONF.service_available.glance, + 'baremetal': CONF.service_available.ironic, 'volume': CONF.service_available.cinder, 'orchestration': CONF.service_available.heat, # NOTE(mtreinish) nova-network will provide networking functionality