]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
heat engine : Convert WaitConditionHandle to pre-signed URLs
authorSteven Hardy <shardy@redhat.com>
Mon, 26 Nov 2012 11:26:00 +0000 (11:26 +0000)
committerSteven Hardy <shardy@redhat.com>
Thu, 29 Nov 2012 20:20:27 +0000 (20:20 +0000)
Change WaitConditionHandle so it provides a pre-signed URL which
allows authenticated wait condition notification via the CFN API

blueprint metsrv-remove
Change-Id: I5c1c3a17ade35c810e49b1f27d80bcfea9e89485
Signed-off-by: Steven Hardy <shardy@redhat.com>
etc/heat/heat-engine.conf
heat/common/ec2signer.py [new file with mode: 0644]
heat/engine/resources/wait_condition.py
heat/tests/test_waitcondition.py

index 172aa244a0a4c746480270cee7d9eb76f6b6656c..82a471757e39a5c5cfb28b65b1194f64692a8013 100644 (file)
@@ -25,9 +25,9 @@ heat_metadata_server_url = http://127.0.0.1:8000
 # of waitcondition events (ie via cfn-signal)
 # e.g the IP of the bridge device connecting the
 # instances with the host and the bind_port of
-# the heat-metadata API
+# the CFN API
 # NOTE : change this from 127.0.0.1 !!
-heat_waitcondition_server_url = http://127.0.0.1:8002
+heat_waitcondition_server_url = http://127.0.0.1:8000/v1/waitcondition
 
 # URL for instances to connect for publishing metric
 # data (ie via cfn-push-stats)
diff --git a/heat/common/ec2signer.py b/heat/common/ec2signer.py
new file mode 100644 (file)
index 0000000..8da5404
--- /dev/null
@@ -0,0 +1,105 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack LLC
+# Copyright 2010 United States Government as represented by the
+# Administrator of the National Aeronautics and Space Administration.
+# Copyright 2011 - 2012 Justin Santa Barbara
+# 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 base64
+import hashlib
+import hmac
+import urllib
+
+# FIXME : This should be imported from keystoneclient, so this can be removed
+# when we no longer require an internal fallback implementation
+# see : https://review.openstack.org/#/c/16964/
+# https://blueprints.launchpad.net/keystone/+spec/ec2signer-to-keystoneclient
+
+
+class Ec2Signer(object):
+    """
+    Utility class which adds allows a request to be signed with an AWS style
+    signature, which can then be used for authentication via the keystone ec2
+    authentication extension
+    """
+
+    def __init__(self, secret_key):
+        secret_key = secret_key.encode()
+        self.hmac = hmac.new(secret_key, digestmod=hashlib.sha1)
+        if hashlib.sha256:
+            self.hmac_256 = hmac.new(secret_key, digestmod=hashlib.sha256)
+
+    def generate(self, credentials):
+        """Generate auth string according to what SignatureVersion is given."""
+        if credentials['params']['SignatureVersion'] == '0':
+            return self._calc_signature_0(credentials['params'])
+        if credentials['params']['SignatureVersion'] == '1':
+            return self._calc_signature_1(credentials['params'])
+        if credentials['params']['SignatureVersion'] == '2':
+            return self._calc_signature_2(credentials['params'],
+                                          credentials['verb'],
+                                          credentials['host'],
+                                          credentials['path'])
+        raise Exception('Unknown Signature Version: %s' %
+                        credentials['params']['SignatureVersion'])
+
+    @staticmethod
+    def _get_utf8_value(value):
+        """Get the UTF8-encoded version of a value."""
+        if not isinstance(value, str) and not isinstance(value, unicode):
+            value = str(value)
+        if isinstance(value, unicode):
+            return value.encode('utf-8')
+        else:
+            return value
+
+    def _calc_signature_0(self, params):
+        """Generate AWS signature version 0 string."""
+        s = params['Action'] + params['Timestamp']
+        self.hmac.update(s)
+        return base64.b64encode(self.hmac.digest())
+
+    def _calc_signature_1(self, params):
+        """Generate AWS signature version 1 string."""
+        keys = params.keys()
+        keys.sort(cmp=lambda x, y: cmp(x.lower(), y.lower()))
+        for key in keys:
+            self.hmac.update(key)
+            val = self._get_utf8_value(params[key])
+            self.hmac.update(val)
+        return base64.b64encode(self.hmac.digest())
+
+    def _calc_signature_2(self, params, verb, server_string, path):
+        """Generate AWS signature version 2 string."""
+        string_to_sign = '%s\n%s\n%s\n' % (verb, server_string, path)
+        if self.hmac_256:
+            current_hmac = self.hmac_256
+            params['SignatureMethod'] = 'HmacSHA256'
+        else:
+            current_hmac = self.hmac
+            params['SignatureMethod'] = 'HmacSHA1'
+        keys = params.keys()
+        keys.sort()
+        pairs = []
+        for key in keys:
+            val = self._get_utf8_value(params[key])
+            val = urllib.quote(val, safe='-_~')
+            pairs.append(urllib.quote(key, safe='') + '=' + val)
+        qs = '&'.join(pairs)
+        string_to_sign += qs
+        current_hmac.update(string_to_sign)
+        b64 = base64.b64encode(current_hmac.digest())
+        return b64
index 39ce59fa223e54f470e25d97a62ca76b4954940f..c57662746e0cbff675e8ccc6902f29b63800424b 100644 (file)
@@ -14,6 +14,9 @@
 #    under the License.
 
 import eventlet
+import time
+import urllib
+import urlparse
 
 from heat.common import exception
 from heat.engine import resource
@@ -22,6 +25,16 @@ from heat.openstack.common import log as logging
 
 from heat.openstack.common import cfg
 
+# FIXME : we should remove the common.ec2signer fallback implementation
+# when the versions of keystoneclient we support all have the Ec2Signer
+# utility class
+# Ref https://review.openstack.org/#/c/16964/
+# https://blueprints.launchpad.net/keystone/+spec/ec2signer-to-keystoneclient
+try:
+    from keystoneclient.contrib.ec2.utils import Ec2Signer
+except ImportError:
+    from heat.common.ec2signer import Ec2Signer
+
 logger = logging.getLogger('heat.engine.wait_condition')
 
 
@@ -38,15 +51,63 @@ class WaitConditionHandle(resource.Resource):
     def __init__(self, name, json_snippet, stack):
         super(WaitConditionHandle, self).__init__(name, json_snippet, stack)
 
+    def _sign_url(self, credentials, path):
+        """
+        Create properly formatted and pre-signed URL using supplied credentials
+        See http://docs.amazonwebservices.com/AWSECommerceService/latest/DG/
+            rest-signature.html
+        Also see boto/auth.py::QuerySignatureV2AuthHandler
+        """
+        host_url = urlparse.urlparse(cfg.CONF.heat_waitcondition_server_url)
+        request = {'host': host_url.netloc.lower(),
+                   'verb': 'PUT',
+                   'path': host_url.path + path,
+                   'params': {'SignatureMethod': 'HmacSHA256',
+                              'SignatureVersion': '2',
+                              'AWSAccessKeyId': credentials.access,
+                              'Timestamp': time.strftime("%Y-%m-%dT%H:%M:%SZ",
+                                                         time.gmtime())}}
+        # Sign the request
+        signer = Ec2Signer(credentials.secret)
+        request['params']['Signature'] = signer.generate(request)
+
+        qs = urllib.urlencode(request['params'])
+        url = "%s%s?%s" % (cfg.CONF.heat_waitcondition_server_url.lower(),
+                           path, qs)
+        return url
+
     def handle_create(self):
-        self.resource_id = '%s/stacks/%s/resources/%s' % \
-                           (cfg.CONF.heat_waitcondition_server_url,
-                            self.stack.id,
-                            self.name)
+        # Create a keystone user so we can create a signed URL via FnGetRefId
+        user_id = self.keystone().create_stack_user(
+                   self.physical_resource_name())
+        kp = self.keystone().get_ec2_keypair(user_id)
+        if not kp:
+            raise exception.Error("Error creating ec2 keypair for user %s" %
+                                  user_id)
+        else:
+            self.resource_id_set(user_id)
+
+    def handle_delete(self):
+        if self.resource_id is None:
+            return
+        self.keystone().delete_stack_user(self.resource_id)
 
     def handle_update(self):
         return self.UPDATE_REPLACE
 
+    def FnGetRefId(self):
+        '''
+        Override the default resource FnGetRefId so we return the signed URL
+        '''
+        if self.resource_id:
+            urlpath = '/%s/resources/%s' % (self.stack.id, self.name)
+            ec2_creds = self.keystone().get_ec2_keypair(self.resource_id)
+            signed_url = self._sign_url(ec2_creds, urlpath)
+            return unicode(signed_url)
+        else:
+            return unicode(self.name)
+
+
 WAIT_STATUSES = (
     WAITING,
     TIMEDOUT,
@@ -87,7 +148,7 @@ class WaitCondition(resource.Resource):
     def _get_handle_resource_id(self):
         if self.resource_id is None:
             handle_url = self.properties['Handle']
-            self.resource_id = handle_url.split('/')[-1]
+            self.resource_id = handle_url.split('/')[-1].split('?')[0]
         return self.resource_id
 
     def _get_status_reason(self, handle):
index c42f23cdfe1f253a2c1815aee5ffa682df56bd73..59699696f4065e7546c37ac9b3fae15560df29c0 100644 (file)
@@ -17,17 +17,22 @@ import json
 import logging
 import mox
 import sys
+import uuid
+import time
 
 import eventlet
 import nose
 import unittest
 from nose.plugins.attrib import attr
+from heat.tests import fakes
 
 import heat.db as db_api
 from heat.engine import format
 from heat.engine import parser
 from heat.engine.resources import wait_condition as wc
 from heat.common import context
+from heat.common import config
+from heat.openstack.common import cfg
 
 logger = logging.getLogger('test_waitcondition')
 
@@ -52,9 +57,9 @@ test_template_waitcondition = '''
 '''
 
 
-@attr(tag=['unit', 'resource'])
+@attr(tag=['unit', 'resource', 'WaitCondition'])
 @attr(speed='slow')
-class stacksTest(unittest.TestCase):
+class WaitConditionTest(unittest.TestCase):
     def setUp(self):
         self.m = mox.Mox()
         self.m.StubOutWithMock(wc.WaitCondition,
@@ -63,6 +68,12 @@ class stacksTest(unittest.TestCase):
                                '_create_timeout')
         self.m.StubOutWithMock(eventlet, 'sleep')
 
+        config.register_engine_opts()
+        cfg.CONF.set_default('heat_waitcondition_server_url',
+                             'http://127.0.0.1:8000/v1/waitcondition')
+
+        self.fc = fakes.FakeKeystoneClient()
+
     def tearDown(self):
         self.m.UnsetStubs()
 
@@ -90,6 +101,9 @@ class stacksTest(unittest.TestCase):
         wc.WaitCondition._get_status_reason(
                          mox.IgnoreArg()).AndReturn(('SUCCESS', 'woot toot'))
 
+        self.m.StubOutWithMock(wc.WaitConditionHandle, 'keystone')
+        wc.WaitConditionHandle.keystone().MultipleTimes().AndReturn(self.fc)
+
         self.m.ReplayAll()
 
         stack.create()
@@ -118,6 +132,9 @@ class stacksTest(unittest.TestCase):
                          mox.IgnoreArg()).AndReturn(('WAITING', ''))
         eventlet.sleep(1).AndRaise(tmo)
 
+        self.m.StubOutWithMock(wc.WaitConditionHandle, 'keystone')
+        wc.WaitConditionHandle.keystone().MultipleTimes().AndReturn(self.fc)
+
         self.m.ReplayAll()
 
         stack.create()
@@ -133,7 +150,78 @@ class stacksTest(unittest.TestCase):
 
         self.m.VerifyAll()
 
-    # allows testing of the test directly
-    if __name__ == '__main__':
-        sys.argv.append(__file__)
-        nose.main()
+
+@attr(tag=['unit', 'resource', 'WaitConditionHandle'])
+@attr(speed='fast')
+class WaitConditionHandleTest(unittest.TestCase):
+    def setUp(self):
+        self.m = mox.Mox()
+        config.register_engine_opts()
+        cfg.CONF.set_default('heat_waitcondition_server_url',
+                             'http://127.0.0.1:8000/v1/waitcondition')
+
+        self.fc = fakes.FakeKeystoneClient()
+
+    def tearDown(self):
+        self.m.UnsetStubs()
+
+    def create_stack(self, stack_name='test_stack2', params={}):
+        temp = format.parse_to_template(test_template_waitcondition)
+        template = parser.Template(temp)
+        parameters = parser.Parameters(stack_name, template, params)
+        stack = parser.Stack(context.get_admin_context(), stack_name,
+                             template, parameters)
+        # Stub out the UUID for this test, so we can get an expected signature
+        self.m.StubOutWithMock(uuid, 'uuid4')
+        uuid.uuid4().AndReturn('STACKABCD1234')
+        self.m.ReplayAll()
+        stack.store()
+        return stack
+
+    def test_handle(self):
+        stack = self.create_stack()
+
+        # Stub waitcondition status so all goes CREATE_COMPLETE
+        self.m.StubOutWithMock(wc.WaitCondition, '_get_status_reason')
+        wc.WaitCondition._get_status_reason(
+                         mox.IgnoreArg()).AndReturn(('SUCCESS', 'woot toot'))
+        self.m.StubOutWithMock(wc.WaitCondition, '_create_timeout')
+        wc.WaitCondition._create_timeout().AndReturn(eventlet.Timeout(5))
+
+        # Stub keystone() with fake client
+        self.m.StubOutWithMock(wc.WaitConditionHandle, 'keystone')
+        wc.WaitConditionHandle.keystone().MultipleTimes().AndReturn(self.fc)
+
+        # Stub time to a fixed value so we can get an expected signature
+        t = time.gmtime(1354196977)
+        self.m.StubOutWithMock(time, 'gmtime')
+        time.gmtime().MultipleTimes().AndReturn(t)
+
+        self.m.ReplayAll()
+        stack.create()
+
+        resource = stack.resources['WaitHandle']
+        self.assertEqual(resource.state, 'CREATE_COMPLETE')
+
+        expected_url = "".join(
+                       ["http://127.0.0.1:8000/v1/waitcondition/STACKABCD1234",
+                       "/resources/WaitHandle",
+                       "?Timestamp=2012-11-29T13%3A49%3A37Z",
+                       "&SignatureMethod=HmacSHA256",
+                       "&AWSAccessKeyId=4567",
+                       "&SignatureVersion=2",
+                       "&Signature=",
+                       "%2BY5r9xvxTzTrRkz%2Br5T1wGeFwoU1wTh2c5u8a2sCurQ%3D"])
+        self.assertEqual(expected_url, resource.FnGetRefId())
+
+        self.assertEqual(resource.UPDATE_REPLACE,
+                  resource.handle_update())
+
+        stack.delete()
+
+        self.m.VerifyAll()
+
+# allows testing of the test directly
+if __name__ == '__main__':
+    sys.argv.append(__file__)
+    nose.main()