Merge "Adds Ironic test_baremetal_basic_ops scenario test"
diff --git a/etc/tempest.conf.sample b/etc/tempest.conf.sample
index 2fdbb7e..50dc00c 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 3521df0..a9e7aeb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,6 +13,7 @@
 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 693ca41..444b4d9 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 @@
     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 @@
         # 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 @@
             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 @@
                                                  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 0212d8a..723504c 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -844,13 +844,29 @@
 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 f06a850..d7be534 100644
--- a/tempest/scenario/manager.py
+++ b/tempest/scenario/manager.py
@@ -19,6 +19,7 @@
 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 @@
             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 @@
         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 @@
         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 @@
         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 0000000..c53aa83
--- /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 e4019f9..8df405c 100644
--- a/tempest/test.py
+++ b/tempest/test.py
@@ -94,6 +94,7 @@
     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