summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorspisarski <s.pisarski@cablelabs.com>2017-06-02 15:31:53 -0600
committerspisarski <s.pisarski@cablelabs.com>2017-06-05 13:22:49 -0600
commit48da17bfedb683b624faf08d2e0b7552d56cff21 (patch)
tree9219ed4ab9872b26f7ff685c4d3378212a641d08
parentc01f193cad22895f86f726f588a46e44ed4ab68a (diff)
Added support for applying Heat Templates
Second patch expanded support to both files and dict() objects. Third patch exposes new accessor for status and outputs. JIRA: SNAPS-86 Change-Id: Ie7e8d883b4cc1a08dbe851fc9cbf663396334909 Signed-off-by: spisarski <s.pisarski@cablelabs.com>
-rw-r--r--examples/heat/test_heat_template.yaml42
-rw-r--r--setup.py1
-rw-r--r--snaps/domain/stack.py29
-rw-r--r--snaps/domain/test/stack_tests.py33
-rw-r--r--snaps/file_utils.py13
-rw-r--r--snaps/openstack/create_stack.py214
-rw-r--r--snaps/openstack/tests/create_stack_tests.py308
-rw-r--r--snaps/openstack/utils/heat_utils.py139
-rw-r--r--snaps/openstack/utils/tests/heat_utils_tests.py143
-rw-r--r--snaps/provisioning/heat/__init__.py15
-rw-r--r--snaps/test_suite_builder.py16
11 files changed, 953 insertions, 0 deletions
diff --git a/examples/heat/test_heat_template.yaml b/examples/heat/test_heat_template.yaml
new file mode 100644
index 0000000..d81a71c
--- /dev/null
+++ b/examples/heat/test_heat_template.yaml
@@ -0,0 +1,42 @@
+heat_template_version: 2015-04-30
+
+description: Simple template to deploy a single compute instance
+
+parameters:
+ image_name:
+ type: string
+ label: Image ID
+ description: Image to be used for compute instance
+ default: heat_utils_tests
+ flavor_name:
+ type: string
+ label: Instance Type
+ description: Type of instance (flavor) to be used
+ default: m1.small
+
+resources:
+ private_net:
+ type: OS::Neutron::Net
+ properties:
+ name: test_net
+
+ private_subnet:
+ type: OS::Neutron::Subnet
+ properties:
+ network_id: { get_resource: private_net }
+ cidr: 10.0.0.0/24
+
+ server1_port:
+ type: OS::Neutron::Port
+ properties:
+ network_id: { get_resource: private_net }
+ fixed_ips:
+ - subnet_id: { get_resource: private_subnet }
+
+ my_instance:
+ type: OS::Nova::Server
+ properties:
+ image: { get_param: image_name }
+ flavor: { get_param: flavor_name }
+ networks:
+ - port: { get_resource: server1_port }
diff --git a/setup.py b/setup.py
index 47c9233..46376e4 100644
--- a/setup.py
+++ b/setup.py
@@ -31,6 +31,7 @@ config = {
'python-neutronclient>=5.1.0',
'python-keystoneclient>=2.3.1',
'python-glanceclient>=2.5.0',
+ 'python-heatclient',
'ansible>=2.1.0',
'wrapt',
'scp',
diff --git a/snaps/domain/stack.py b/snaps/domain/stack.py
new file mode 100644
index 0000000..eaa45b3
--- /dev/null
+++ b/snaps/domain/stack.py
@@ -0,0 +1,29 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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.
+
+
+class Stack:
+ """
+ SNAPS domain object for Heat Stacks. Should contain attributes that
+ are shared amongst cloud providers
+ """
+ def __init__(self, name, stack_id):
+ """
+ Constructor
+ :param name: the stack's name
+ :param stack_id: the stack's stack_id
+ """
+ self.name = name
+ self.id = stack_id
diff --git a/snaps/domain/test/stack_tests.py b/snaps/domain/test/stack_tests.py
new file mode 100644
index 0000000..a6fd8a3
--- /dev/null
+++ b/snaps/domain/test/stack_tests.py
@@ -0,0 +1,33 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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 unittest
+from snaps.domain.stack import Stack
+
+
+class StackDomainObjectTests(unittest.TestCase):
+ """
+ Tests the construction of the snaps.domain.test.Stack class
+ """
+
+ def test_construction_positional(self):
+ stack = Stack('name', 'id')
+ self.assertEqual('name', stack.name)
+ self.assertEqual('id', stack.id)
+
+ def test_construction_named(self):
+ stack = Stack(stack_id='id', name='name')
+ self.assertEqual('name', stack.name)
+ self.assertEqual('id', stack.id)
diff --git a/snaps/file_utils.py b/snaps/file_utils.py
index 819c707..f7c9af4 100644
--- a/snaps/file_utils.py
+++ b/snaps/file_utils.py
@@ -123,3 +123,16 @@ def read_os_env_file(os_env_filename):
# Remove leading and trailing ' & " characters from value
out[tokens[0]] = tokens[1].lstrip('\'').lstrip('\"').rstrip('\'').rstrip('\"')
return out
+
+
+def read_file(filename):
+ """
+ Returns the contents of a file as a string
+ :param filename: the name of the file
+ :return:
+ """
+ out = str()
+ for line in open(filename):
+ out += line
+
+ return out
diff --git a/snaps/openstack/create_stack.py b/snaps/openstack/create_stack.py
new file mode 100644
index 0000000..8dc5027
--- /dev/null
+++ b/snaps/openstack/create_stack.py
@@ -0,0 +1,214 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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 logging
+import time
+
+from heatclient.exc import HTTPNotFound
+
+from snaps.openstack.utils import heat_utils
+
+__author__ = 'spisarski'
+
+logger = logging.getLogger('create_stack')
+
+STACK_COMPLETE_TIMEOUT = 1200
+POLL_INTERVAL = 3
+STATUS_CREATE_COMPLETE = 'CREATE_COMPLETE'
+STATUS_DELETE_COMPLETE = 'DELETE_COMPLETE'
+
+
+class OpenStackHeatStack:
+ """
+ Class responsible for creating an heat stack in OpenStack
+ """
+
+ def __init__(self, os_creds, stack_settings):
+ """
+ Constructor
+ :param os_creds: The OpenStack connection credentials
+ :param stack_settings: The stack settings
+ :return:
+ """
+ self.__os_creds = os_creds
+ self.stack_settings = stack_settings
+ self.__stack = None
+ self.__heat_cli = None
+
+ def create(self, cleanup=False):
+ """
+ Creates the heat stack in OpenStack if it does not already exist and returns the domain Stack object
+ :param cleanup: Denotes whether or not this is being called for cleanup or not
+ :return: The OpenStack Stack object
+ """
+ self.__heat_cli = heat_utils.heat_client(self.__os_creds)
+ self.__stack = heat_utils.get_stack_by_name(self.__heat_cli, self.stack_settings.name)
+ if self.__stack:
+ logger.info('Found stack with name - ' + self.stack_settings.name)
+ return self.__stack
+ elif not cleanup:
+ self.__stack = heat_utils.create_stack(self.__heat_cli, self.stack_settings)
+ logger.info('Created stack with name - ' + self.stack_settings.name)
+ if self.__stack and self.stack_complete(block=True):
+ logger.info('Stack is now active with name - ' + self.stack_settings.name)
+ return self.__stack
+ else:
+ raise StackCreationError('Stack was not created or activated in the alloted amount of time')
+ else:
+ logger.info('Did not create stack due to cleanup mode')
+
+ return self.__stack
+
+ def clean(self):
+ """
+ Cleanse environment of all artifacts
+ :return: void
+ """
+ if self.__stack:
+ try:
+ heat_utils.delete_stack(self.__heat_cli, self.__stack)
+ except HTTPNotFound:
+ pass
+
+ self.__stack = None
+
+ def get_stack(self):
+ """
+ Returns the domain Stack object as it was populated when create() was called
+ :return: the object
+ """
+ return self.__stack
+
+ def get_outputs(self):
+ """
+ Returns the list of outputs as contained on the OpenStack Heat Stack object
+ :return:
+ """
+ return heat_utils.get_stack_outputs(self.__heat_cli, self.__stack.id)
+
+ def get_status(self):
+ """
+ Returns the list of outputs as contained on the OpenStack Heat Stack object
+ :return:
+ """
+ return heat_utils.get_stack_status(self.__heat_cli, self.__stack.id)
+
+ def stack_complete(self, block=False, timeout=None, poll_interval=POLL_INTERVAL):
+ """
+ Returns true when the stack status returns the value of expected_status_code
+ :param block: When true, thread will block until active or timeout value in seconds has been exceeded (False)
+ :param timeout: The timeout value
+ :param poll_interval: The polling interval in seconds
+ :return: T/F
+ """
+ if not timeout:
+ timeout = self.stack_settings.stack_create_timeout
+ return self._stack_status_check(STATUS_CREATE_COMPLETE, block, timeout, poll_interval)
+
+ def _stack_status_check(self, expected_status_code, block, timeout, poll_interval):
+ """
+ Returns true when the stack status returns the value of expected_status_code
+ :param expected_status_code: stack status evaluated with this string value
+ :param block: When true, thread will block until active or timeout value in seconds has been exceeded (False)
+ :param timeout: The timeout value
+ :param poll_interval: The polling interval in seconds
+ :return: T/F
+ """
+ # sleep and wait for stack status change
+ if block:
+ start = time.time()
+ else:
+ start = time.time() - timeout
+
+ while timeout > time.time() - start:
+ status = self._status(expected_status_code)
+ if status:
+ logger.debug('Stack is active with name - ' + self.stack_settings.name)
+ return True
+
+ logger.debug('Retry querying stack status in ' + str(poll_interval) + ' seconds')
+ time.sleep(poll_interval)
+ logger.debug('Stack status query timeout in ' + str(timeout - (time.time() - start)))
+
+ logger.error('Timeout checking for stack status for ' + expected_status_code)
+ return False
+
+ def _status(self, expected_status_code):
+ """
+ Returns True when active else False
+ :param expected_status_code: stack status evaluated with this string value
+ :return: T/F
+ """
+ status = self.get_status()
+ if not status:
+ logger.warning('Cannot stack status for stack with ID - ' + self.__stack.id)
+ return False
+
+ if status == 'ERROR':
+ raise StackCreationError('Stack had an error during deployment')
+ logger.debug('Stack status is - ' + status)
+ return status == expected_status_code
+
+
+class StackSettings:
+ def __init__(self, config=None, name=None, template=None, template_path=None, env_values=None,
+ stack_create_timeout=STACK_COMPLETE_TIMEOUT):
+ """
+ Constructor
+ :param config: dict() object containing the configuration settings using the attribute names below as each
+ member's the key and overrides any of the other parameters.
+ :param name: the stack's name (required)
+ :param template: the heat template in dict() format (required if template_path attribute is None)
+ :param template_path: the location of the heat template file (required if template attribute is None)
+ :param env_values: k/v pairs of strings for substitution of template default values (optional)
+ """
+
+ if config:
+ self.name = config.get('name')
+ self.template = config.get('template')
+ self.template_path = config.get('template_path')
+ self.env_values = config.get('env_values')
+ if 'stack_create_timeout' in config:
+ self.stack_create_timeout = config['stack_create_timeout']
+ else:
+ self.stack_create_timeout = stack_create_timeout
+ else:
+ self.name = name
+ self.template = template
+ self.template_path = template_path
+ self.env_values = env_values
+ self.stack_create_timeout = stack_create_timeout
+
+ if not self.name:
+ raise StackSettingsError('name is required')
+
+ if not self.template and not self.template_path:
+ raise StackSettingsError('A Heat template is required')
+
+
+class StackSettingsError(Exception):
+ """
+ Exception to be thrown when an stack settings are incorrect
+ """
+ def __init__(self, message):
+ Exception.__init__(self, message)
+
+
+class StackCreationError(Exception):
+ """
+ Exception to be thrown when an stack cannot be created
+ """
+ def __init__(self, message):
+ Exception.__init__(self, message)
diff --git a/snaps/openstack/tests/create_stack_tests.py b/snaps/openstack/tests/create_stack_tests.py
new file mode 100644
index 0000000..fa75475
--- /dev/null
+++ b/snaps/openstack/tests/create_stack_tests.py
@@ -0,0 +1,308 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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 time
+
+from heatclient.exc import HTTPBadRequest
+from snaps import file_utils
+
+from snaps.openstack.create_flavor import OpenStackFlavor, FlavorSettings
+
+from snaps.openstack.create_image import OpenStackImage
+
+try:
+ from urllib.request import URLError
+except ImportError:
+ from urllib2 import URLError
+
+import logging
+import unittest
+import uuid
+
+from snaps.openstack import create_stack
+from snaps.openstack.create_stack import StackSettings, StackSettingsError
+from snaps.openstack.tests import openstack_tests
+from snaps.openstack.tests.os_source_file_test import OSIntegrationTestCase
+from snaps.openstack.utils import heat_utils
+
+__author__ = 'spisarski'
+
+logger = logging.getLogger('create_stack_tests')
+
+
+class StackSettingsUnitTests(unittest.TestCase):
+ """
+ Tests the construction of the StackSettings class
+ """
+ def test_no_params(self):
+ with self.assertRaises(StackSettingsError):
+ StackSettings()
+
+ def test_empty_config(self):
+ with self.assertRaises(StackSettingsError):
+ StackSettings(config=dict())
+
+ def test_name_only(self):
+ with self.assertRaises(StackSettingsError):
+ StackSettings(name='foo')
+
+ def test_config_with_name_only(self):
+ with self.assertRaises(StackSettingsError):
+ StackSettings(config={'name': 'foo'})
+
+ def test_config_minimum_template(self):
+ settings = StackSettings(config={'name': 'stack', 'template': 'foo'})
+ self.assertEqual('stack', settings.name)
+ self.assertEqual('foo', settings.template)
+ self.assertIsNone(settings.template_path)
+ self.assertIsNone(settings.env_values)
+ self.assertEqual(create_stack.STACK_COMPLETE_TIMEOUT, settings.stack_create_timeout)
+
+ def test_config_minimum_template_path(self):
+ settings = StackSettings(config={'name': 'stack', 'template_path': 'foo'})
+ self.assertEqual('stack', settings.name)
+ self.assertIsNone(settings.template)
+ self.assertEqual('foo', settings.template_path)
+ self.assertIsNone(settings.env_values)
+ self.assertEqual(create_stack.STACK_COMPLETE_TIMEOUT, settings.stack_create_timeout)
+
+ def test_minimum_template(self):
+ settings = StackSettings(name='stack', template='foo')
+ self.assertEqual('stack', settings.name)
+ self.assertEqual('foo', settings.template)
+ self.assertIsNone(settings.template_path)
+ self.assertIsNone(settings.env_values)
+ self.assertEqual(create_stack.STACK_COMPLETE_TIMEOUT, settings.stack_create_timeout)
+
+ def test_minimum_template_path(self):
+ settings = StackSettings(name='stack', template_path='foo')
+ self.assertEqual('stack', settings.name)
+ self.assertEqual('foo', settings.template_path)
+ self.assertIsNone(settings.template)
+ self.assertIsNone(settings.env_values)
+ self.assertEqual(create_stack.STACK_COMPLETE_TIMEOUT, settings.stack_create_timeout)
+
+ def test_all(self):
+ env_values = {'foo': 'bar'}
+ settings = StackSettings(name='stack', template='bar', template_path='foo', env_values=env_values,
+ stack_create_timeout=999)
+ self.assertEqual('stack', settings.name)
+ self.assertEqual('bar', settings.template)
+ self.assertEqual('foo', settings.template_path)
+ self.assertEqual(env_values, settings.env_values)
+ self.assertEqual(999, settings.stack_create_timeout)
+
+ def test_config_all(self):
+ env_values = {'foo': 'bar'}
+ settings = StackSettings(
+ config={'name': 'stack', 'template': 'bar', 'template_path': 'foo',
+ 'env_values': env_values, 'stack_create_timeout': 999})
+ self.assertEqual('stack', settings.name)
+ self.assertEqual('bar', settings.template)
+ self.assertEqual('foo', settings.template_path)
+ self.assertEqual(env_values, settings.env_values)
+ self.assertEqual(999, settings.stack_create_timeout)
+
+
+class CreateStackSuccessTests(OSIntegrationTestCase):
+ """
+ Test for the CreateStack class defined in create_stack.py
+ """
+
+ def setUp(self):
+ """
+ Instantiates the CreateStack object that is responsible for downloading and creating an OS stack file
+ within OpenStack
+ """
+ super(self.__class__, self).__start__()
+
+ self.guid = str(uuid.uuid4())
+ self.heat_cli = heat_utils.heat_client(self.os_creds)
+ self.stack_creator = None
+
+ self.image_creator = OpenStackImage(
+ self.os_creds, openstack_tests.cirros_image_settings(
+ name=self.__class__.__name__ + '-' + str(self.guid) + '-image'))
+ self.image_creator.create()
+
+ # Create Flavor
+ self.flavor_creator = OpenStackFlavor(
+ self.admin_os_creds,
+ FlavorSettings(name=self.guid + '-flavor-name', ram=128, disk=10, vcpus=1))
+ self.flavor_creator.create()
+
+ self.env_values = {'image_name': self.image_creator.image_settings.name,
+ 'flavor_name': self.flavor_creator.flavor_settings.name}
+
+ def tearDown(self):
+ """
+ Cleans the stack and downloaded stack file
+ """
+ if self.stack_creator:
+ self.stack_creator.clean()
+
+ if self.image_creator:
+ try:
+ self.image_creator.clean()
+ except:
+ pass
+
+ if self.flavor_creator:
+ try:
+ self.flavor_creator.clean()
+ except:
+ pass
+
+ super(self.__class__, self).__clean__()
+
+ def test_create_stack_template_file(self):
+ """
+ Tests the creation of an OpenStack stack from Heat template file.
+ """
+ # Create Stack
+ # Set the default stack settings, then set any custom parameters sent from the app
+ stack_settings = StackSettings(name=self.__class__.__name__ + '-' + str(self.guid) + '-stack',
+ template_path='../examples/heat/test_heat_template.yaml',
+ env_values=self.env_values)
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ created_stack = self.stack_creator.create()
+ self.assertIsNotNone(created_stack)
+
+ retrieved_stack = heat_utils.get_stack_by_id(self.heat_cli, created_stack.id)
+ self.assertIsNotNone(retrieved_stack)
+ self.assertEqual(created_stack.name, retrieved_stack.name)
+ self.assertEqual(created_stack.id, retrieved_stack.id)
+ self.assertIsNotNone(self.stack_creator.get_outputs())
+ self.assertEquals(0, len(self.stack_creator.get_outputs()))
+
+ def test_create_stack_template_dict(self):
+ """
+ Tests the creation of an OpenStack stack from a heat dict() object.
+ """
+ # Create Stack
+ # Set the default stack settings, then set any custom parameters sent from the app
+ template_dict = heat_utils.parse_heat_template_str(
+ file_utils.read_file('../examples/heat/test_heat_template.yaml'))
+ stack_settings = StackSettings(name=self.__class__.__name__ + '-' + str(self.guid) + '-stack',
+ template=template_dict,
+ env_values=self.env_values)
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ created_stack = self.stack_creator.create()
+ self.assertIsNotNone(created_stack)
+
+ retrieved_stack = heat_utils.get_stack_by_id(self.heat_cli, created_stack.id)
+ self.assertIsNotNone(retrieved_stack)
+ self.assertEqual(created_stack.name, retrieved_stack.name)
+ self.assertEqual(created_stack.id, retrieved_stack.id)
+ self.assertIsNotNone(self.stack_creator.get_outputs())
+ self.assertEquals(0, len(self.stack_creator.get_outputs()))
+
+ def test_create_delete_stack(self):
+ """
+ Tests the creation then deletion of an OpenStack stack to ensure clean() does not raise an Exception.
+ """
+ # Create Stack
+ template_dict = heat_utils.parse_heat_template_str(
+ file_utils.read_file('../examples/heat/test_heat_template.yaml'))
+ stack_settings = StackSettings(name=self.__class__.__name__ + '-' + str(self.guid) + '-stack',
+ template=template_dict,
+ env_values=self.env_values)
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ created_stack = self.stack_creator.create()
+ self.assertIsNotNone(created_stack)
+
+ retrieved_stack = heat_utils.get_stack_by_id(self.heat_cli, created_stack.id)
+ self.assertIsNotNone(retrieved_stack)
+ self.assertEqual(created_stack.name, retrieved_stack.name)
+ self.assertEqual(created_stack.id, retrieved_stack.id)
+ self.assertIsNotNone(self.stack_creator.get_outputs())
+ self.assertEquals(0, len(self.stack_creator.get_outputs()))
+ self.assertEqual(create_stack.STATUS_CREATE_COMPLETE, self.stack_creator.get_status())
+
+ # Delete Stack manually
+ heat_utils.delete_stack(self.heat_cli, created_stack)
+
+ end_time = time.time() + 90
+ deleted = False
+ while time.time() < end_time:
+ status = heat_utils.get_stack_status(self.heat_cli, retrieved_stack.id)
+ if status == create_stack.STATUS_DELETE_COMPLETE:
+ deleted = True
+ break
+
+ self.assertTrue(deleted)
+
+ # Must not throw an exception when attempting to cleanup non-existent stack
+ self.stack_creator.clean()
+ self.assertIsNone(self.stack_creator.get_stack())
+
+ def test_create_same_stack(self):
+ """
+ Tests the creation of an OpenStack stack when the stack already exists.
+ """
+ # Create Stack
+ template_dict = heat_utils.parse_heat_template_str(
+ file_utils.read_file('../examples/heat/test_heat_template.yaml'))
+ stack_settings = StackSettings(name=self.__class__.__name__ + '-' + str(self.guid) + '-stack',
+ template=template_dict,
+ env_values=self.env_values)
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ created_stack1 = self.stack_creator.create()
+
+ retrieved_stack = heat_utils.get_stack_by_id(self.heat_cli, created_stack1.id)
+ self.assertIsNotNone(retrieved_stack)
+ self.assertEqual(created_stack1.name, retrieved_stack.name)
+ self.assertEqual(created_stack1.id, retrieved_stack.id)
+ self.assertIsNotNone(self.stack_creator.get_outputs())
+ self.assertEqual(0, len(self.stack_creator.get_outputs()))
+
+ # Should be retrieving the instance data
+ stack_creator2 = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ stack2 = stack_creator2.create()
+ self.assertEqual(created_stack1.id, stack2.id)
+
+
+class CreateStackNegativeTests(OSIntegrationTestCase):
+ """
+ Negative test cases for the CreateStack class
+ """
+
+ def setUp(self):
+ super(self.__class__, self).__start__()
+
+ self.stack_name = self.__class__.__name__ + '-' + str(uuid.uuid4())
+ self.stack_creator = None
+
+ def tearDown(self):
+ if self.stack_creator:
+ self.stack_creator.clean()
+ super(self.__class__, self).__clean__()
+
+ def test_missing_dependencies(self):
+ """
+ Expect an StackCreationError when the stack file does not exist
+ """
+ stack_settings = StackSettings(name=self.stack_name, template_path='../examples/heat/test_heat_template.yaml')
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ with self.assertRaises(HTTPBadRequest):
+ self.stack_creator.create()
+
+ def test_bad_stack_file(self):
+ """
+ Expect an StackCreationError when the stack file does not exist
+ """
+ stack_settings = StackSettings(name=self.stack_name, template_path='foo')
+ self.stack_creator = create_stack.OpenStackHeatStack(self.os_creds, stack_settings)
+ with self.assertRaises(IOError):
+ self.stack_creator.create()
diff --git a/snaps/openstack/utils/heat_utils.py b/snaps/openstack/utils/heat_utils.py
new file mode 100644
index 0000000..d40e3b9
--- /dev/null
+++ b/snaps/openstack/utils/heat_utils.py
@@ -0,0 +1,139 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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 logging
+
+import yaml
+from heatclient.client import Client
+from heatclient.common.template_format import yaml_loader
+from oslo_serialization import jsonutils
+
+from snaps import file_utils
+from snaps.domain.stack import Stack
+
+from snaps.openstack.utils import keystone_utils
+
+__author__ = 'spisarski'
+
+logger = logging.getLogger('heat_utils')
+
+
+def heat_client(os_creds):
+ """
+ Retrieves the Heat client
+ :param os_creds: the OpenStack credentials
+ :return: the client
+ """
+ logger.debug('Retrieving Nova Client')
+ return Client(1, session=keystone_utils.keystone_session(os_creds))
+
+
+def get_stack_by_name(heat_cli, stack_name):
+ """
+ Returns a domain Stack object
+ :param heat_cli: the OpenStack heat client
+ :param stack_name: the name of the heat stack
+ :return: the Stack domain object else None
+ """
+ stacks = heat_cli.stacks.list(**{'name': stack_name})
+ for stack in stacks:
+ return Stack(name=stack.identifier, stack_id=stack.id)
+
+ return None
+
+
+def get_stack_by_id(heat_cli, stack_id):
+ """
+ Returns a domain Stack object for a given ID
+ :param heat_cli: the OpenStack heat client
+ :param stack_id: the ID of the heat stack to retrieve
+ :return: the Stack domain object else None
+ """
+ stack = heat_cli.stacks.get(stack_id)
+ return Stack(name=stack.identifier, stack_id=stack.id)
+
+
+def get_stack_status(heat_cli, stack_id):
+ """
+ Returns the current status of the Heat stack
+ :param heat_cli: the OpenStack heat client
+ :param stack_id: the ID of the heat stack to retrieve
+ :return:
+ """
+ return heat_cli.stacks.get(stack_id).stack_status
+
+
+def get_stack_outputs(heat_cli, stack_id):
+ """
+ Returns a domain Stack object for a given ID
+ :param heat_cli: the OpenStack heat client
+ :param stack_id: the ID of the heat stack to retrieve
+ :return: the Stack domain object else None
+ """
+ stack = heat_cli.stacks.get(stack_id)
+ return stack.outputs
+
+
+def create_stack(heat_cli, stack_settings):
+ """
+ Executes an Ansible playbook to the given host
+ :param heat_cli: the OpenStack heat client object
+ :param stack_settings: the stack configuration
+ :return: the Stack domain object
+ """
+ args = dict()
+
+ if stack_settings.template:
+ args['template'] = stack_settings.template
+ else:
+ args['template'] = parse_heat_template_str(file_utils.read_file(stack_settings.template_path))
+ args['stack_name'] = stack_settings.name
+
+ if stack_settings.env_values:
+ args['parameters'] = stack_settings.env_values
+
+ stack = heat_cli.stacks.create(**args)
+
+ return get_stack_by_id(heat_cli, stack_id=stack['stack']['id'])
+
+
+def delete_stack(heat_cli, stack):
+ """
+ Deletes the Heat stack
+ :param heat_cli: the OpenStack heat client object
+ :param stack: the OpenStack Heat stack object
+ """
+ heat_cli.stacks.delete(stack.id)
+
+
+def parse_heat_template_str(tmpl_str):
+ """Takes a heat template string, performs some simple validation and returns a dict containing the parsed structure.
+ This function supports both JSON and YAML Heat template formats.
+ """
+ if tmpl_str.startswith('{'):
+ tpl = jsonutils.loads(tmpl_str)
+ else:
+ try:
+ tpl = yaml.load(tmpl_str, Loader=yaml_loader)
+ except yaml.YAMLError as yea:
+ raise ValueError(yea)
+ else:
+ if tpl is None:
+ tpl = {}
+ # Looking for supported version keys in the loaded template
+ if not ('HeatTemplateFormatVersion' in tpl or
+ 'heat_template_version' in tpl or
+ 'AWSTemplateFormatVersion' in tpl):
+ raise ValueError("Template format version not found.")
+ return tpl
diff --git a/snaps/openstack/utils/tests/heat_utils_tests.py b/snaps/openstack/utils/tests/heat_utils_tests.py
new file mode 100644
index 0000000..08387d8
--- /dev/null
+++ b/snaps/openstack/utils/tests/heat_utils_tests.py
@@ -0,0 +1,143 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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 logging
+import uuid
+
+import time
+
+from snaps.openstack import create_stack
+from snaps.openstack.create_flavor import OpenStackFlavor, FlavorSettings
+
+from snaps.openstack.create_image import OpenStackImage
+from snaps.openstack.create_stack import StackSettings
+from snaps.openstack.tests import openstack_tests
+from snaps.openstack.tests.os_source_file_test import OSComponentTestCase
+from snaps.openstack.utils import heat_utils
+
+__author__ = 'spisarski'
+
+logger = logging.getLogger('nova_utils_tests')
+
+
+class HeatSmokeTests(OSComponentTestCase):
+ """
+ Tests to ensure that the nova client can communicate with the cloud
+ """
+
+ def test_nova_connect_success(self):
+ """
+ Tests to ensure that the proper credentials can connect.
+ """
+ heat = heat_utils.heat_client(self.os_creds)
+
+ # This should not throw an exception
+ heat.stacks.list()
+
+ def test_nova_connect_fail(self):
+ """
+ Tests to ensure that the improper credentials cannot connect.
+ """
+ from snaps.openstack.os_credentials import OSCreds
+
+ nova = heat_utils.heat_client(
+ OSCreds(username='user', password='pass', auth_url=self.os_creds.auth_url,
+ project_name=self.os_creds.project_name, proxy_settings=self.os_creds.proxy_settings))
+
+ # This should throw an exception
+ with self.assertRaises(Exception):
+ nova.flavors.list()
+
+
+class HeatUtilsCreateStackTests(OSComponentTestCase):
+ """
+ Test basic nova keypair functionality
+ """
+
+ def setUp(self):
+ """
+ Instantiates the CreateImage object that is responsible for downloading and creating an OS image file
+ within OpenStack
+ """
+ guid = self.__class__.__name__ + '-' + str(uuid.uuid4())
+ stack_name = self.__class__.__name__ + '-' + str(guid) + '-stack'
+
+ self.image_creator = OpenStackImage(
+ self.os_creds, openstack_tests.cirros_image_settings(
+ name=self.__class__.__name__ + '-' + str(guid) + '-image'))
+ self.image_creator.create()
+
+ # Create Flavor
+ self.flavor_creator = OpenStackFlavor(
+ self.os_creds,
+ FlavorSettings(name=guid + '-flavor', ram=128, disk=10, vcpus=1))
+ self.flavor_creator.create()
+
+ env_values = {'image_name': self.image_creator.image_settings.name,
+ 'flavor_name': self.flavor_creator.flavor_settings.name}
+ self.stack_settings = StackSettings(name=stack_name, template_path='../examples/heat/test_heat_template.yaml',
+ env_values=env_values)
+ self.stack = None
+ self.heat_client = heat_utils.heat_client(self.os_creds)
+
+ def tearDown(self):
+ """
+ Cleans the image and downloaded image file
+ """
+ if self.stack:
+ try:
+ heat_utils.delete_stack(self.heat_client, self.stack)
+ except:
+ pass
+
+ if self.image_creator:
+ try:
+ self.image_creator.clean()
+ except:
+ pass
+
+ if self.flavor_creator:
+ try:
+ self.flavor_creator.clean()
+ except:
+ pass
+
+ def test_create_stack(self):
+ """
+ Tests the creation of an OpenStack keypair that does not exist.
+ """
+ self.stack = heat_utils.create_stack(self.heat_client, self.stack_settings)
+
+ stack_query_1 = heat_utils.get_stack_by_name(self.heat_client, self.stack_settings.name)
+ self.assertEqual(self.stack.id, stack_query_1.id)
+
+ stack_query_2 = heat_utils.get_stack_by_id(self.heat_client, self.stack.id)
+ self.assertEqual(self.stack.id, stack_query_2.id)
+
+ outputs = heat_utils.get_stack_outputs(self.heat_client, self.stack.id)
+ self.assertIsNotNone(outputs)
+ self.assertEqual(0, len(outputs))
+
+ end_time = time.time() + create_stack.STACK_COMPLETE_TIMEOUT
+
+ is_active = False
+ while time.time() < end_time:
+ status = heat_utils.get_stack_status(self.heat_client, self.stack.id)
+ if status == create_stack.STATUS_CREATE_COMPLETE:
+ is_active = True
+ break
+
+ time.sleep(3)
+
+ self.assertTrue(is_active)
diff --git a/snaps/provisioning/heat/__init__.py b/snaps/provisioning/heat/__init__.py
new file mode 100644
index 0000000..271c742
--- /dev/null
+++ b/snaps/provisioning/heat/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (c) 2017 Cable Television Laboratories, Inc. ("CableLabs")
+# and others. All rights reserved.
+#
+# 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.
+__author__ = 'spisarski'
diff --git a/snaps/test_suite_builder.py b/snaps/test_suite_builder.py
index 76495ce..7e0c76a 100644
--- a/snaps/test_suite_builder.py
+++ b/snaps/test_suite_builder.py
@@ -17,8 +17,12 @@ import logging
import unittest
from snaps.domain.test.image_tests import ImageDomainObjectTests
+from snaps.domain.test.stack_tests import StackDomainObjectTests
+from snaps.openstack.tests.create_stack_tests import StackSettingsUnitTests, CreateStackSuccessTests, \
+ CreateStackNegativeTests
from snaps.openstack.utils.tests.glance_utils_tests import GlanceSmokeTests, GlanceUtilsTests
from snaps.openstack.tests.create_flavor_tests import CreateFlavorTests
+from snaps.openstack.utils.tests.heat_utils_tests import HeatUtilsCreateStackTests, HeatSmokeTests
from snaps.tests.file_utils_tests import FileUtilsTests
from snaps.openstack.tests.create_security_group_tests import CreateSecurityGroupTests, \
SecurityGroupRuleSettingsUnitTests, SecurityGroupSettingsUnitTests
@@ -65,6 +69,8 @@ def add_unit_tests(suite):
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(PortSettingsUnitTests))
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(FloatingIpSettingsUnitTests))
suite.addTest(unittest.TestLoader().loadTestsFromTestCase(VmInstanceSettingsUnitTests))
+ suite.addTest(unittest.TestLoader().loadTestsFromTestCase(StackDomainObjectTests))
+ suite.addTest(unittest.TestLoader().loadTestsFromTestCase(StackSettingsUnitTests))
def add_openstack_client_tests(suite, os_creds, ext_net_name, use_keystone=True, log_level=logging.INFO):
@@ -90,6 +96,8 @@ def add_openstack_client_tests(suite, os_creds, ext_net_name, use_keystone=True,
log_level=log_level))
suite.addTest(OSComponentTestCase.parameterize(NovaSmokeTests, os_creds=os_creds, ext_net_name=ext_net_name,
log_level=log_level))
+ suite.addTest(OSComponentTestCase.parameterize(HeatSmokeTests, os_creds=os_creds, ext_net_name=ext_net_name,
+ log_level=log_level))
def add_openstack_api_tests(suite, os_creds, ext_net_name, use_keystone=True, image_metadata=None,
@@ -134,6 +142,8 @@ def add_openstack_api_tests(suite, os_creds, ext_net_name, use_keystone=True, im
NovaUtilsFlavorTests, os_creds=os_creds, ext_net_name=ext_net_name, log_level=log_level))
suite.addTest(OSComponentTestCase.parameterize(
CreateFlavorTests, os_creds=os_creds, ext_net_name=ext_net_name, log_level=log_level))
+ suite.addTest(OSComponentTestCase.parameterize(
+ HeatUtilsCreateStackTests, os_creds=os_creds, ext_net_name=ext_net_name, log_level=log_level))
def add_openstack_integration_tests(suite, os_creds, ext_net_name, use_keystone=True, flavor_metadata=None,
@@ -202,6 +212,12 @@ def add_openstack_integration_tests(suite, os_creds, ext_net_name, use_keystone=
suite.addTest(OSIntegrationTestCase.parameterize(
CreateInstanceFromThreePartImage, os_creds=os_creds, ext_net_name=ext_net_name, use_keystone=use_keystone,
flavor_metadata=flavor_metadata, image_metadata=image_metadata, log_level=log_level))
+ suite.addTest(OSIntegrationTestCase.parameterize(
+ CreateStackSuccessTests, os_creds=os_creds, ext_net_name=ext_net_name, use_keystone=use_keystone,
+ flavor_metadata=flavor_metadata, image_metadata=image_metadata, log_level=log_level))
+ suite.addTest(OSIntegrationTestCase.parameterize(
+ CreateStackNegativeTests, os_creds=os_creds, ext_net_name=ext_net_name, use_keystone=use_keystone,
+ flavor_metadata=flavor_metadata, image_metadata=image_metadata, log_level=log_level))
if use_floating_ips:
suite.addTest(OSIntegrationTestCase.parameterize(