Added support for applying Heat Templates 53/35753/3
authorspisarski <s.pisarski@cablelabs.com>
Fri, 2 Jun 2017 21:31:53 +0000 (15:31 -0600)
committerspisarski <s.pisarski@cablelabs.com>
Mon, 5 Jun 2017 19:22:49 +0000 (13:22 -0600)
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>
examples/heat/test_heat_template.yaml [new file with mode: 0644]
setup.py
snaps/domain/stack.py [new file with mode: 0644]
snaps/domain/test/stack_tests.py [new file with mode: 0644]
snaps/file_utils.py
snaps/openstack/create_stack.py [new file with mode: 0644]
snaps/openstack/tests/create_stack_tests.py [new file with mode: 0644]
snaps/openstack/utils/heat_utils.py [new file with mode: 0644]
snaps/openstack/utils/tests/heat_utils_tests.py [new file with mode: 0644]
snaps/provisioning/heat/__init__.py [new file with mode: 0644]
snaps/test_suite_builder.py

diff --git a/examples/heat/test_heat_template.yaml b/examples/heat/test_heat_template.yaml
new file mode 100644 (file)
index 0000000..d81a71c
--- /dev/null
@@ -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 }
index 47c9233..46376e4 100644 (file)
--- 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 (file)
index 0000000..eaa45b3
--- /dev/null
@@ -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 (file)
index 0000000..a6fd8a3
--- /dev/null
@@ -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)
index 819c707..f7c9af4 100644 (file)
@@ -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 (file)
index 0000000..8dc5027
--- /dev/null
@@ -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 (file)
index 0000000..fa75475
--- /dev/null
@@ -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 (file)
index 0000000..d40e3b9
--- /dev/null
@@ -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 (file)
index 0000000..08387d8
--- /dev/null
@@ -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 (file)
index 0000000..271c742
--- /dev/null
@@ -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'
index 76495ce..7e0c76a 100644 (file)
@@ -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(