diff --git a/etc/tempest.conf.sample b/etc/tempest.conf.sample index 2fdbb7e9cf..50dc00c0e2 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 3521df0f3a..a9e7aeb6bf 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 693ca415dd..444b4d9a82 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 @@ -463,6 +464,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 @@ -472,6 +474,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) @@ -492,6 +495,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 @@ -613,6 +632,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 0212d8a6b8..723504c544 100644 --- a/tempest/config.py +++ b/tempest/config.py @@ -844,13 +844,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 e4019f9b69..8df405c903 100644 --- a/tempest/test.py +++ b/tempest/test.py @@ -94,6 +94,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