heat engine : Convert WaitConditionHandle to pre-signed URLs
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>
This commit is contained in:
parent
d4bb435ed2
commit
17dd71b017
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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):
|
||||
|
|
|
@ -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__':
|
||||
|
||||
@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()
|
||||
|
|
Loading…
Reference in New Issue