ARIA multivim plugin initial checkin

Change-Id: I3a24ab6fc5ba54466bfecaf596a13b8907248ae8
Issue-id: SO-77
Signed-off-by: DeWayne Filppi <dewayne@gigaspaces.com>
diff --git a/aria/multivim-plugin/glance_plugin/__init__.py b/aria/multivim-plugin/glance_plugin/__init__.py
new file mode 100644
index 0000000..809f033
--- /dev/null
+++ b/aria/multivim-plugin/glance_plugin/__init__.py
@@ -0,0 +1,14 @@
+#########
+# Copyright (c) 2015 GigaSpaces Technologies Ltd. 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.
diff --git a/aria/multivim-plugin/glance_plugin/image.py b/aria/multivim-plugin/glance_plugin/image.py
new file mode 100644
index 0000000..a8d5b20
--- /dev/null
+++ b/aria/multivim-plugin/glance_plugin/image.py
@@ -0,0 +1,177 @@
+#########
+# Copyright (c) 2015 GigaSpaces Technologies Ltd. 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 httplib
+from urlparse import urlparse
+
+from cloudify import ctx
+from cloudify.decorators import operation
+from cloudify.exceptions import NonRecoverableError
+
+from openstack_plugin_common import (
+    with_glance_client,
+    get_resource_id,
+    use_external_resource,
+    get_openstack_ids_of_connected_nodes_by_openstack_type,
+    delete_resource_and_runtime_properties,
+    validate_resource,
+    COMMON_RUNTIME_PROPERTIES_KEYS,
+    OPENSTACK_ID_PROPERTY,
+    OPENSTACK_TYPE_PROPERTY,
+    OPENSTACK_NAME_PROPERTY)
+
+
+IMAGE_OPENSTACK_TYPE = 'image'
+IMAGE_STATUS_ACTIVE = 'active'
+
+RUNTIME_PROPERTIES_KEYS = COMMON_RUNTIME_PROPERTIES_KEYS
+REQUIRED_PROPERTIES = ['container_format', 'disk_format']
+
+
+@operation
+@with_glance_client
+def create(glance_client, **kwargs):
+    if use_external_resource(ctx, glance_client, IMAGE_OPENSTACK_TYPE):
+        return
+
+    img_dict = {
+        'name': get_resource_id(ctx, IMAGE_OPENSTACK_TYPE)
+    }
+    _validate_image_dictionary()
+    img_properties = ctx.node.properties['image']
+    img_dict.update({key: value for key, value in img_properties.iteritems()
+                    if key != 'data'})
+    img = glance_client.images.create(**img_dict)
+    img_path = img_properties.get('data', '')
+    img_url = ctx.node.properties.get('image_url')
+    try:
+        _validate_image()
+        if img_path:
+            with open(img_path, 'rb') as image_file:
+                glance_client.images.upload(
+                    image_id=img.id,
+                    image_data=image_file)
+        elif img_url:
+            img = glance_client.images.add_location(img.id, img_url, {})
+
+    except:
+        _remove_protected(glance_client)
+        glance_client.images.delete(image_id=img.id)
+        raise
+
+    ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY] = img.id
+    ctx.instance.runtime_properties[OPENSTACK_TYPE_PROPERTY] = \
+        IMAGE_OPENSTACK_TYPE
+    ctx.instance.runtime_properties[OPENSTACK_NAME_PROPERTY] = img.name
+
+
+def _get_image_by_ctx(glance_client, ctx):
+    return glance_client.images.get(
+        image_id=ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY])
+
+
+@operation
+@with_glance_client
+def start(glance_client, start_retry_interval, **kwargs):
+    img = _get_image_by_ctx(glance_client, ctx)
+    if img.status != IMAGE_STATUS_ACTIVE:
+        return ctx.operation.retry(
+            message='Waiting for image to get uploaded',
+            retry_after=start_retry_interval)
+
+
+@operation
+@with_glance_client
+def delete(glance_client, **kwargs):
+    _remove_protected(glance_client)
+    delete_resource_and_runtime_properties(ctx, glance_client,
+                                           RUNTIME_PROPERTIES_KEYS)
+
+
+@operation
+@with_glance_client
+def creation_validation(glance_client, **kwargs):
+    validate_resource(ctx, glance_client, IMAGE_OPENSTACK_TYPE)
+    _validate_image_dictionary()
+    _validate_image()
+
+
+def _validate_image_dictionary():
+    img = ctx.node.properties['image']
+    missing = ''
+    try:
+        for prop in REQUIRED_PROPERTIES:
+            if prop not in img:
+                missing += '{0} '.format(prop)
+    except TypeError:
+        missing = ' '.join(REQUIRED_PROPERTIES)
+    if missing:
+        raise NonRecoverableError('Required properties are missing: {'
+                                  '0}. Please update your image '
+                                  'dictionary.'.format(missing))
+
+
+def _validate_image():
+    img = ctx.node.properties['image']
+    img_path = img.get('data')
+    img_url = ctx.node.properties.get('image_url')
+    if not img_url and not img_path:
+        raise NonRecoverableError('Neither image url nor image path was '
+                                  'provided')
+    if img_url and img_path:
+        raise NonRecoverableError('Multiple image sources provided')
+    if img_url:
+        _check_url(img_url)
+    if img_path:
+        _check_path()
+
+
+def _check_url(url):
+    p = urlparse(url)
+    conn = httplib.HTTPConnection(p.netloc)
+    conn.request('HEAD', p.path)
+    resp = conn.getresponse()
+    if resp.status >= 400:
+        raise NonRecoverableError('Invalid image URL')
+
+
+def _check_path():
+    img = ctx.node.properties['image']
+    img_path = img.get('data')
+    try:
+        with open(img_path, 'rb'):
+            pass
+    except TypeError:
+        if not img.get('url'):
+            raise NonRecoverableError('No path or url provided')
+    except IOError:
+        raise NonRecoverableError(
+            'Unable to open image file with path: "{}"'.format(img_path))
+
+
+def _remove_protected(glance_client):
+    if use_external_resource(ctx, glance_client, IMAGE_OPENSTACK_TYPE):
+        return
+
+    is_protected = ctx.node.properties['image'].get('protected', False)
+    if is_protected:
+        img_id = ctx.instance.runtime_properties[OPENSTACK_ID_PROPERTY]
+        glance_client.images.update(img_id, protected=False)
+
+
+def handle_image_from_relationship(obj_dict, property_name_to_put, ctx):
+    images = get_openstack_ids_of_connected_nodes_by_openstack_type(
+        ctx, IMAGE_OPENSTACK_TYPE)
+    if images:
+        obj_dict.update({property_name_to_put: images[0]})
diff --git a/aria/multivim-plugin/glance_plugin/tests/resources/test-image-start.yaml b/aria/multivim-plugin/glance_plugin/tests/resources/test-image-start.yaml
new file mode 100644
index 0000000..12c9aa7
--- /dev/null
+++ b/aria/multivim-plugin/glance_plugin/tests/resources/test-image-start.yaml
@@ -0,0 +1,30 @@
+
+tosca_definitions_version: cloudify_dsl_1_3
+
+imports:
+  - https://raw.githubusercontent.com/cloudify-cosmo/cloudify-manager/4.1/resources/rest-service/cloudify/types/types.yaml
+  - plugin.yaml
+
+inputs:
+  use_password:
+    type: boolean
+    default: false
+
+node_templates:
+  image:
+    type: cloudify.openstack.nodes.Image
+    properties:
+      image:
+        disk_format: test_format
+        container_format: test_format
+        data: test_path
+      openstack_config:
+        username: aaa
+        password: aaa
+        tenant_name: aaa
+        auth_url: aaa
+    interfaces:
+      cloudify.interfaces.lifecycle:
+        start:
+          inputs:
+            start_retry_interval: 1
diff --git a/aria/multivim-plugin/glance_plugin/tests/test.py b/aria/multivim-plugin/glance_plugin/tests/test.py
new file mode 100644
index 0000000..4a88cba
--- /dev/null
+++ b/aria/multivim-plugin/glance_plugin/tests/test.py
@@ -0,0 +1,148 @@
+#########
+# Copyright (c) 2014 GigaSpaces Technologies Ltd. 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 mock
+import os
+import tempfile
+import unittest
+
+import glance_plugin
+from glance_plugin import image
+
+from cloudify.mocks import MockCloudifyContext
+from cloudify.test_utils import workflow_test
+from cloudify.exceptions import NonRecoverableError
+
+
+def ctx_mock(image_dict):
+    return MockCloudifyContext(
+        node_id='d',
+        properties=image_dict)
+
+
+class TestCheckImage(unittest.TestCase):
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image': {}}))
+    def test_check_image_no_file_no_url(self):
+        # Test if it throws exception no file & no url
+        self.assertRaises(NonRecoverableError,
+                          image._validate_image)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image_url': 'test-url', 'image': {'data': '.'}}))
+    def test_check_image_and_url(self):
+        # Test if it throws exception file & url
+        self.assertRaises(NonRecoverableError,
+                          image._validate_image)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image_url': 'test-url', 'image': {}}))
+    def test_check_image_url(self):
+        # test if it passes no file & url
+        http_connection_mock = mock.MagicMock()
+        http_connection_mock.return_value.getresponse.return_value.status = 200
+        with mock.patch('httplib.HTTPConnection', http_connection_mock):
+            glance_plugin.image._validate_image()
+
+    def test_check_image_file(self):
+        # test if it passes file & no url
+        image_file_path = tempfile.mkstemp()[1]
+        with mock.patch('glance_plugin.image.ctx',
+                        ctx_mock({'image': {'data': image_file_path}})):
+            glance_plugin.image._validate_image()
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image': {'data': '/test/path'}}))
+    # test when open file throws IO error
+    def test_check_image_bad_file(self):
+        open_name = '%s.open' % __name__
+        with mock.patch(open_name, create=True) as mock_open:
+            mock_open.side_effect = [mock_open(read_data='Data').return_value]
+            self.assertRaises(NonRecoverableError,
+                              glance_plugin.image._validate_image)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image_url': '?', 'image': {}}))
+    # test when bad url
+    def test_check_image_bad_url(self):
+        http_connection_mock = mock.MagicMock()
+        http_connection_mock.return_value.getresponse.return_value.status = 400
+        with mock.patch('httplib.HTTPConnection', http_connection_mock):
+            self.assertRaises(NonRecoverableError,
+                              glance_plugin.image._validate_image)
+
+
+class TestValidateProperties(unittest.TestCase):
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image': {'container_format': 'bare'}}))
+    def test_check_image_container_format_no_disk_format(self):
+        # Test if it throws exception no file & no url
+        self.assertRaises(NonRecoverableError,
+                          image._validate_image_dictionary)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image': {'disk_format': 'qcow2'}}))
+    def test_check_image_no_container_format_disk_format(self):
+        # Test if it throws exception no container_format & disk_format
+        self.assertRaises(NonRecoverableError,
+                          image._validate_image_dictionary)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock({'image': {}}))
+    def test_check_image_no_container_format_no_disk_format(self):
+        # Test if it throws exception no container_format & no disk_format
+        self.assertRaises(NonRecoverableError,
+                          image._validate_image_dictionary)
+
+    @mock.patch('glance_plugin.image.ctx',
+                ctx_mock(
+                    {'image':
+                        {'container_format': 'bare',
+                         'disk_format': 'qcow2'}}))
+    def test_check_image_container_format_disk_format(self):
+        # Test if it do not throw exception container_format & disk_format
+        image._validate_image_dictionary()
+
+
+class TestStartImage(unittest.TestCase):
+    blueprint_path = os.path.join('resources',
+                                  'test-image-start.yaml')
+
+    @mock.patch('glance_plugin.image.create')
+    @workflow_test(blueprint_path, copy_plugin_yaml=True)
+    def test_image_lifecycle_start(self, cfy_local, *_):
+        test_vars = {
+            'counter': 0,
+            'image': mock.MagicMock()
+        }
+
+        def _mock_get_image_by_ctx(*_):
+            i = test_vars['image']
+            if test_vars['counter'] == 0:
+                i.status = 'different image status'
+            else:
+                i.status = glance_plugin.image.IMAGE_STATUS_ACTIVE
+            test_vars['counter'] += 1
+            return i
+
+        with mock.patch('openstack_plugin_common.GlanceClient'):
+            with mock.patch('glance_plugin.image._get_image_by_ctx',
+                            side_effect=_mock_get_image_by_ctx):
+                cfy_local.execute('install', task_retries=3)
+
+        self.assertEqual(2, test_vars['counter'])
+        self.assertEqual(0, test_vars['image'].start.call_count)