diff --git a/etc/heat/heat-engine.conf b/etc/heat/heat-engine.conf index 172aa244a0..82a471757e 100644 --- a/etc/heat/heat-engine.conf +++ b/etc/heat/heat-engine.conf @@ -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 index 0000000000..8da5404f16 --- /dev/null +++ b/heat/common/ec2signer.py @@ -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 diff --git a/heat/engine/resources/wait_condition.py b/heat/engine/resources/wait_condition.py index 39ce59fa22..c57662746e 100644 --- a/heat/engine/resources/wait_condition.py +++ b/heat/engine/resources/wait_condition.py @@ -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): diff --git a/heat/tests/test_waitcondition.py b/heat/tests/test_waitcondition.py index c42f23cdfe..59699696f4 100644 --- a/heat/tests/test_waitcondition.py +++ b/heat/tests/test_waitcondition.py @@ -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()