Initial import of tests from the Zodiac project. On suggestion from Westmaas, imported tests under the nova directory
(final naming TBD) to more quickly get them imported. To run these tests, execute 'nosetests nova/tests'.
I've also only submitted the most stable of the tests. More to come.
Change-Id: I2abd961992c02b27c4deaa9f11a49ba91c5b765d
Fixed config defaults
Change-Id: I90d5ea20167caddbec6b4cf51a0df9bb333514cb
diff --git a/storm/__init__.py b/storm/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/__init__.py
diff --git a/storm/common/__init__.py b/storm/common/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/common/__init__.py
diff --git a/storm/common/rest_client.py b/storm/common/rest_client.py
new file mode 100644
index 0000000..44658fa
--- /dev/null
+++ b/storm/common/rest_client.py
@@ -0,0 +1,96 @@
+import httplib2
+import json
+import storm.config
+
+
+class RestClient(object):
+
+ def __init__(self, user, key, auth_url, tenant_name=None):
+ self.config = storm.config.StormConfig()
+
+ if self.config.env.authentication == 'keystone_v2':
+ self.token, self.base_url = self.keystone_v2_auth(user,
+ key,
+ auth_url,
+ tenant_name)
+ else:
+ self.token, self.base_url = self.basic_auth(user,
+ key,
+ auth_url)
+
+ def basic_auth(self, user, api_key, auth_url):
+ """
+ Provides authentication for the target API
+ """
+
+ params = {}
+ params['headers'] = {'User-Agent': 'Test-Client', 'X-Auth-User': user,
+ 'X-Auth-Key': api_key}
+
+ self.http_obj = httplib2.Http()
+ resp, body = self.http_obj.request(auth_url, 'GET', **params)
+ try:
+ return resp['x-auth-token'], resp['x-server-management-url']
+ except:
+ raise
+
+ def keystone_v2_auth(self, user, api_key, auth_url, tenant_name):
+ """
+ Provides authentication via Keystone 2.0
+ """
+
+ creds = {'auth': {
+ 'passwordCredentials': {
+ 'username': user,
+ 'password': api_key,
+ },
+ 'tenantName': tenant_name
+ }
+ }
+
+ self.http_obj = httplib2.Http()
+ headers = {'Content-Type': 'application/json'}
+ body = json.dumps(creds)
+ resp, body = self.http_obj.request(auth_url, 'POST',
+ headers=headers, body=body)
+
+ try:
+ auth_data = json.loads(body)['access']
+ token = auth_data['token']['id']
+ endpoints = auth_data['serviceCatalog'][0]['endpoints']
+ mgmt_url = endpoints[0]['publicURL']
+
+ #TODO (dwalleck): This is a horrible stopgap.
+ #Need to join strings more cleanly
+ temp = mgmt_url.rsplit('/')
+ service_url = temp[0] + '//' + temp[2] + '/' + temp[3] + '/'
+ management_url = service_url + tenant_name
+ return token, management_url
+ except KeyError:
+ print "Failed to authenticate user"
+ raise
+
+ def post(self, url, body, headers):
+ return self.request('POST', url, headers, body)
+
+ def get(self, url):
+ return self.request('GET', url)
+
+ def delete(self, url):
+ return self.request('DELETE', url)
+
+ def put(self, url, body, headers):
+ return self.request('PUT', url, headers, body)
+
+ def request(self, method, url, headers=None, body=None):
+ """ A simple HTTP request interface."""
+
+ self.http_obj = httplib2.Http()
+ if headers == None:
+ headers = {}
+ headers['X-Auth-Token'] = self.token
+
+ req_url = "%s/%s" % (self.base_url, url)
+ resp, body = self.http_obj.request(req_url, method,
+ headers=headers, body=body)
+ return resp, body
diff --git a/storm/common/ssh.py b/storm/common/ssh.py
new file mode 100644
index 0000000..2f1d96b
--- /dev/null
+++ b/storm/common/ssh.py
@@ -0,0 +1,79 @@
+import time
+import socket
+import warnings
+
+with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ import paramiko
+
+
+class Client(object):
+
+ def __init__(self, host, username, password, timeout=300):
+ self.host = host
+ self.username = username
+ self.password = password
+ self.timeout = int(timeout)
+
+ def _get_ssh_connection(self):
+ """Returns an ssh connection to the specified host"""
+ _timeout = True
+ ssh = paramiko.SSHClient()
+ ssh.set_missing_host_key_policy(
+ paramiko.AutoAddPolicy())
+ _start_time = time.time()
+
+ while not self._is_timed_out(self.timeout, _start_time):
+ try:
+ ssh.connect(self.host, username=self.username,
+ password=self.password, look_for_keys=False,
+ timeout=20)
+ _timeout = False
+ break
+ except socket.error:
+ continue
+ except paramiko.AuthenticationException:
+ time.sleep(15)
+ continue
+ if _timeout:
+ raise socket.error("SSH connect timed out")
+ return ssh
+
+ def _is_timed_out(self, timeout, start_time):
+ return (time.time() - timeout) > start_time
+
+ def connect_until_closed(self):
+ """Connect to the server and wait until connection is lost"""
+ try:
+ ssh = self._get_ssh_connection()
+ _transport = ssh.get_transport()
+ _start_time = time.time()
+ _timed_out = self._is_timed_out(self.timeout, _start_time)
+ while _transport.is_active() and not _timed_out:
+ time.sleep(5)
+ _timed_out = self._is_timed_out(self.timeout, _start_time)
+ ssh.close()
+ except (EOFError, paramiko.AuthenticationException, socket.error):
+ return
+
+ def exec_command(self, cmd):
+ """Execute the specified command on the server.
+
+ :returns: data read from standard output of the command
+
+ """
+ ssh = self._get_ssh_connection()
+ stdin, stdout, stderr = ssh.exec_command(cmd)
+ output = stdout.read()
+ ssh.close()
+ return output
+
+ def test_connection_auth(self):
+ """ Returns true if ssh can connect to server"""
+ try:
+ connection = self._get_ssh_connection()
+ connection.close()
+ except paramiko.AuthenticationException:
+ return False
+
+ return True
diff --git a/storm/common/utils/__init__.py b/storm/common/utils/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/common/utils/__init__.py
diff --git a/storm/common/utils/data_utils.py b/storm/common/utils/data_utils.py
new file mode 100644
index 0000000..e4d9db5
--- /dev/null
+++ b/storm/common/utils/data_utils.py
@@ -0,0 +1,5 @@
+import random
+
+
+def rand_name(self, name='test'):
+ return name + str(random.randint(1, 99999999999))
diff --git a/storm/config.py b/storm/config.py
new file mode 100644
index 0000000..8feb85b
--- /dev/null
+++ b/storm/config.py
@@ -0,0 +1,115 @@
+import ConfigParser
+
+
+class NovaConfig(object):
+ """Provides configuration information for connecting to Nova."""
+
+ def __init__(self, conf):
+ """Initialize a Nova-specific configuration object."""
+ self.conf = conf
+
+ def get(self, item_name, default_value):
+ try:
+ return self.conf.get("nova", item_name)
+ except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
+ return default_value
+
+ @property
+ def auth_url(self):
+ """URL used to authenticate. Defaults to 127.0.0.1."""
+ return self.get("auth_url", "127.0.0.1")
+
+ @property
+ def username(self):
+ """Username to use for Nova API requests. Defaults to 'admin'."""
+ return self.get("user", "admin")
+
+ @property
+ def tenant_name(self):
+ """Tenant name to use for Nova API requests. Defaults to 'admin'."""
+ return self.get("tenant_name", "admin")
+
+ @property
+ def api_key(self):
+ """API key to use when authenticating. Defaults to 'admin_key'."""
+ return self.get("api_key", "admin_key")
+
+ @property
+ def build_interval(self):
+ """Time in seconds between build status checks."""
+ return float(self.get("build_interval", 10))
+
+ @property
+ def ssh_timeout(self):
+ """Timeout in seconds to use when connecting via ssh."""
+ return float(self.get("ssh_timeout", 300))
+
+ @property
+ def build_timeout(self):
+ """Timeout in seconds to wait for an entity to build."""
+ return float(self.get("build_timeout", 300))
+
+
+class EnvironmentConfig(object):
+ def __init__(self, conf):
+ """Initialize a Environment-specific configuration object."""
+ self.conf = conf
+
+ def get(self, item_name, default_value):
+ try:
+ return self.conf.get("environment", item_name)
+ except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
+ return default_value
+
+ @property
+ def image_ref(self):
+ """Valid imageRef to use """
+ return self.get("image_ref", 3)
+
+ @property
+ def image_ref_alt(self):
+ """Valid imageRef to rebuild images with"""
+ return self.get("image_ref_alt", 3)
+
+ @property
+ def flavor_ref(self):
+ """Valid flavorRef to use"""
+ return int(self.get("flavor_ref", 1))
+
+ @property
+ def flavor_ref_alt(self):
+ """Valid flavorRef to resize images with"""
+ return self.get("flavor_ref_alt", 2)
+
+ @property
+ def resize_available(self):
+ """ Does the test environment support resizing """
+ return self.get("resize_available", 'false') != 'false'
+
+ @property
+ def create_image_enabled(self):
+ """ Does the test environment support resizing """
+ return self.get("create_image_enabled", 'false') != 'false'
+
+ @property
+ def authentication(self):
+ """ What auth method does the environment use (basic|keystone) """
+ return self.get("authentication", 'keystone')
+
+
+class StormConfig(object):
+ """Provides OpenStack configuration information."""
+
+ _path = "etc/storm.conf"
+
+ def __init__(self, path=None):
+ """Initialize a configuration from a path."""
+ self._conf = self.load_config(self._path)
+ self.nova = NovaConfig(self._conf)
+ self.env = EnvironmentConfig(self._conf)
+
+ def load_config(self, path=None):
+ """Read configuration from given path and return a config object."""
+ config = ConfigParser.SafeConfigParser()
+ config.read(path)
+ return config
diff --git a/storm/exceptions.py b/storm/exceptions.py
new file mode 100644
index 0000000..8415ccb
--- /dev/null
+++ b/storm/exceptions.py
@@ -0,0 +1,10 @@
+class TimeoutException(Exception):
+ """ Exception on timeout """
+ def __repr__(self):
+ return "Request timed out"
+
+
+class BuildErrorException(Exception):
+ """ Exception on server build """
+ def __repr__(self):
+ return "Server failed into error status"
diff --git a/storm/openstack.py b/storm/openstack.py
new file mode 100644
index 0000000..ff68fce
--- /dev/null
+++ b/storm/openstack.py
@@ -0,0 +1,38 @@
+from storm.services.nova.json.images_client import ImagesClient
+from storm.services.nova.json.flavors_client import FlavorsClient
+from storm.services.nova.json.servers_client import ServersClient
+import storm.config
+
+
+class Manager(object):
+
+ def __init__(self):
+ """
+ Top level manager for all Openstack APIs
+ """
+
+ self.config = storm.config.StormConfig()
+ if self.config.env.authentication == 'keystone_v2':
+ self.servers_client = ServersClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url,
+ self.config.nova.tenant_name)
+ self.flavors_client = FlavorsClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url,
+ self.config.nova.tenant_name)
+ self.images_client = ImagesClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url,
+ self.config.nova.tenant_name)
+ else:
+ #Assuming basic/native authentication
+ self.servers_client = ServersClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url)
+ self.flavors_client = FlavorsClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url)
+ self.images_client = ImagesClient(self.config.nova.username,
+ self.config.nova.api_key,
+ self.config.nova.auth_url)
diff --git a/storm/services/__init__.py b/storm/services/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/services/__init__.py
diff --git a/storm/services/nova/__init__.py b/storm/services/nova/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/services/nova/__init__.py
diff --git a/storm/services/nova/json/__init__.py b/storm/services/nova/json/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/services/nova/json/__init__.py
diff --git a/storm/services/nova/json/flavors_client.py b/storm/services/nova/json/flavors_client.py
new file mode 100644
index 0000000..223644c
--- /dev/null
+++ b/storm/services/nova/json/flavors_client.py
@@ -0,0 +1,41 @@
+from storm.common import rest_client
+import json
+import time
+
+
+class FlavorsClient(object):
+
+ def __init__(self, username, key, auth_url, tenant_name=None):
+ self.client = rest_client.RestClient(username, key,
+ auth_url, tenant_name)
+
+ def list_flavors(self, params=None):
+ url = 'flavors'
+ if params != None:
+ param_list = []
+ for param, value in params.iteritems():
+ param_list.append("%s=%s&" % (param, value))
+
+ url = "flavors?" + "".join(param_list)
+
+ resp, body = self.client.get(url)
+ body = json.loads(body)
+ return resp, body
+
+ def list_flavors_with_detail(self, params=None):
+ url = 'flavors/detail'
+ if params != None:
+ param_list = []
+ for param, value in params.iteritems():
+ param_list.append("%s=%s&" % (param, value))
+
+ url = "flavors/detail?" + "".join(param_list)
+
+ resp, body = self.client.get(url)
+ body = json.loads(body)
+ return resp, body
+
+ def get_flavor_details(self, flavor_id):
+ resp, body = self.client.get("flavors/%s" % str(flavor_id))
+ body = json.loads(body)
+ return resp, body['flavor']
diff --git a/storm/services/nova/xml/__init__.py b/storm/services/nova/xml/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/services/nova/xml/__init__.py
diff --git a/storm/tests/__init__.py b/storm/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storm/tests/__init__.py
diff --git a/storm/tests/test_flavors.py b/storm/tests/test_flavors.py
new file mode 100644
index 0000000..cdac903
--- /dev/null
+++ b/storm/tests/test_flavors.py
@@ -0,0 +1,39 @@
+from nose.plugins.attrib import attr
+from storm import openstack
+import storm.config
+import unittest2 as unittest
+
+
+class FlavorsTest(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.os = openstack.Manager()
+ cls.client = cls.os.flavors_client
+ cls.config = storm.config.StormConfig()
+ cls.flavor_id = cls.config.env.flavor_ref
+
+ @attr(type='smoke')
+ def test_list_flavors(self):
+ """ List of all flavors should contain the expected flavor """
+ resp, body = self.client.list_flavors()
+ flavors = body['flavors']
+
+ resp, flavor = self.client.get_flavor_details(self.flavor_id)
+ flavor_min_detail = {'id': flavor['id'], 'links': flavor['links'],
+ 'name': flavor['name']}
+ self.assertTrue(flavor_min_detail in flavors)
+
+ @attr(type='smoke')
+ def test_list_flavors_with_detail(self):
+ """ Detailed list of all flavors should contain the expected flavor """
+ resp, body = self.client.list_flavors_with_detail()
+ flavors = body['flavors']
+ resp, flavor = self.client.get_flavor_details(self.flavor_id)
+ self.assertTrue(flavor in flavors)
+
+ @attr(type='smoke')
+ def test_get_flavor(self):
+ """ The expected flavor details should be returned """
+ resp, flavor = self.client.get_flavor_details(self.flavor_id)
+ self.assertEqual(self.flavor_id, flavor['id'])
diff --git a/storm/tests/test_server_actions.py b/storm/tests/test_server_actions.py
new file mode 100644
index 0000000..e93ed2b
--- /dev/null
+++ b/storm/tests/test_server_actions.py
@@ -0,0 +1,96 @@
+from nose.plugins.attrib import attr
+from storm import openstack
+import unittest2 as unittest
+import storm.config
+from storm.common.utils.data_utils import rand_name
+
+
+class ServerActionsTest(unittest.TestCase):
+ resize_available = storm.config.StormConfig().env.resize_available
+
+ @classmethod
+ def setUpClass(cls):
+ cls.os = openstack.Manager()
+ cls.client = cls.os.servers_client
+ cls.config = storm.config.StormConfig()
+ cls.image_ref = cls.config.env.image_ref
+ cls.image_ref_alt = cls.config.env.image_ref_alt
+ cls.flavor_ref = cls.config.env.flavor_ref
+ cls.flavor_ref_alt = cls.config.env.flavor_ref_alt
+
+ def setUp(self):
+ self.name = rand_name('server')
+ resp, server = self.client.create_server(self.name, self.image_ref,
+ self.flavor_ref)
+ self.id = server['id']
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+
+ def tearDown(self):
+ self.client.delete_server(self.id)
+
+ @attr(type='smoke')
+ def test_change_server_password(self):
+ """ The server's password should be set to the provided password """
+ resp, body = self.client.change_password(self.id, 'newpass')
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+ #TODO: SSH in to verify the new password works
+
+ @attr(type='smoke')
+ def test_reboot_server_hard(self):
+ """ The server should be power cycled """
+ #TODO: Add validation the server has been rebooted
+
+ resp, body = self.client.reboot(self.id, 'HARD')
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+
+ @attr(type='smoke')
+ def test_reboot_server_soft(self):
+ """ The server should be signaled to reboot gracefully """
+ #TODO: Add validation the server has been rebooted
+
+ resp, body = self.client.reboot(self.id, 'SOFT')
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+
+ @attr(type='smoke')
+ def test_rebuild_server(self):
+ """ The server should be rebuilt using the provided image """
+
+ self.client.rebuild(self.id, self.image_ref_alt, name='rebuiltserver')
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+ resp, server = self.client.get_server(self.id)
+ self.assertEqual(self.image_ref_alt, server['image']['id'])
+ self.assertEqual('rebuiltserver', server['name'])
+
+ @attr(type='smoke')
+ @unittest.skipIf(not resize_available, 'Resize not available.')
+ def test_resize_server_confirm(self):
+ """
+ The server's RAM and disk space should be modified to that of
+ the provided flavor
+ """
+
+ self.client.resize(self.id, self.flavor_ref_alt)
+ self.client.wait_for_server_status(self.id, 'VERIFY_RESIZE')
+
+ self.client.confirm_resize(self.id)
+ self.client.wait_for_server_status(self.id, 'ACTIVE')
+
+ resp, server = self.client.get_server(self.id)
+ self.assertEqual(self.flavor_ref_alt, server['flavor']['id'])
+
+ @attr(type='smoke')
+ @unittest.skipIf(not resize_available, 'Resize not available.')
+ def test_resize_server_revert(self):
+ """
+ The server's RAM and disk space should return to its original
+ values after a resize is reverted
+ """
+
+ self.client.resize(self.id, self.flavor_ref_alt)
+ self.client.wait_for_server_status(id, 'VERIFY_RESIZE')
+
+ self.client.revert_resize(self.id)
+ self.client.wait_for_server_status(id, 'ACTIVE')
+
+ resp, server = self.client.get_server(id)
+ self.assertEqual(self.flavor_ref, server['flavor']['id'])
diff --git a/storm/tests/test_servers.py b/storm/tests/test_servers.py
new file mode 100644
index 0000000..958da69
--- /dev/null
+++ b/storm/tests/test_servers.py
@@ -0,0 +1,118 @@
+from storm.common import ssh
+from nose.plugins.attrib import attr
+from storm import openstack
+from storm.common.utils.data_utils import rand_name
+import base64
+import storm.config
+import unittest2 as unittest
+
+
+class ServersTest(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.os = openstack.Manager()
+ cls.client = cls.os.servers_client
+ cls.config = storm.config.StormConfig()
+ cls.image_ref = cls.config.env.image_ref
+ cls.flavor_ref = cls.config.env.flavor_ref
+ cls.ssh_timeout = cls.config.nova.ssh_timeout
+
+ @attr(type='smoke')
+ def test_create_delete_server(self):
+ meta = {'hello': 'world'}
+ accessIPv4 = '1.1.1.1'
+ accessIPv6 = '::babe:220.12.22.2'
+ name = rand_name('server')
+ file_contents = 'This is a test file.'
+ personality = [{'path': '/etc/test.txt',
+ 'contents': base64.b64encode(file_contents)}]
+ resp, server = self.client.create_server(name,
+ self.image_ref,
+ self.flavor_ref,
+ meta=meta,
+ accessIPv4=accessIPv4,
+ accessIPv6=accessIPv6,
+ personality=personality)
+
+ #Wait for the server to become active
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+
+ #Verify the specified attributes are set correctly
+ resp, server = self.client.get_server(server['id'])
+ self.assertEqual('1.1.1.1', server['accessIPv4'])
+ self.assertEqual('::babe:220.12.22.2', server['accessIPv6'])
+ self.assertEqual(name, server['name'])
+ self.assertEqual(self.image_ref, server['image']['id'])
+ self.assertEqual(str(self.flavor_ref), server['flavor']['id'])
+
+ #Teardown
+ self.client.delete_server(self.id)
+
+ @attr(type='smoke')
+ def test_create_server_with_admin_password(self):
+ """
+ If an admin password is provided on server creation, the server's root
+ password should be set to that password.
+ """
+
+ name = rand_name('server')
+ resp, server = self.client.create_server(name, self.image_ref,
+ self.flavor_ref,
+ adminPass='testpassword')
+
+ #Verify the password is set correctly in the response
+ self.assertEqual('testpassword', server['adminPass'])
+
+ #SSH into the server using the set password
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+ resp, addresses = self.client.list_addresses(server['id'])
+ ip = addresses['public'][0]['addr']
+
+ client = ssh.Client(ip, 'root', 'testpassword', self.ssh_timeout)
+ self.assertTrue(client.test_connection_auth())
+
+ #Teardown
+ self.client.delete_server(server['id'])
+
+ @attr(type='smoke')
+ def test_update_server_name(self):
+ """ The server name should be changed to the the provided value """
+ name = rand_name('server')
+ resp, server = self.client.create_server(name, self.image_ref,
+ self.flavor_ref)
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+
+ #Update the server with a new name
+ self.client.update_server(server['id'], name='newname')
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+
+ #Verify the name of the server has changed
+ resp, server = self.client.get_server(server['id'])
+ self.assertEqual('newname', server['name'])
+
+ #Teardown
+ self.client.delete_server(server['id'])
+
+ @attr(type='smoke')
+ def test_update_access_server_address(self):
+ """
+ The server's access addresses should reflect the provided values
+ """
+ name = rand_name('server')
+ resp, server = self.client.create_server(name, self.image_ref,
+ self.flavor_ref)
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+
+ #Update the IPv4 and IPv6 access addresses
+ self.client.update_server(server['id'], accessIPv4='1.1.1.1',
+ accessIPv6='::babe:2.2.2.2')
+ self.client.wait_for_server_status(server['id'], 'ACTIVE')
+
+ #Verify the access addresses have been updated
+ resp, server = self.client.get_server(server['id'])
+ self.assertEqual('1.1.1.1', server['accessIPv4'])
+ self.assertEqual('::babe:2.2.2.2', server['accessIPv6'])
+
+ #Teardown
+ self.client.delete_server(server['id'])