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:
Steven Hardy 2012-11-26 11:26:00 +00:00
parent d4bb435ed2
commit 17dd71b017
4 changed files with 267 additions and 13 deletions

View File

@ -25,9 +25,9 @@ heat_metadata_server_url = http://127.0.0.1:8000
# of waitcondition events (ie via cfn-signal) # of waitcondition events (ie via cfn-signal)
# e.g the IP of the bridge device connecting the # e.g the IP of the bridge device connecting the
# instances with the host and the bind_port of # instances with the host and the bind_port of
# the heat-metadata API # the CFN API
# NOTE : change this from 127.0.0.1 !! # 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 # URL for instances to connect for publishing metric
# data (ie via cfn-push-stats) # data (ie via cfn-push-stats)

105
heat/common/ec2signer.py Normal file
View File

@ -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

View File

@ -14,6 +14,9 @@
# under the License. # under the License.
import eventlet import eventlet
import time
import urllib
import urlparse
from heat.common import exception from heat.common import exception
from heat.engine import resource from heat.engine import resource
@ -22,6 +25,16 @@ from heat.openstack.common import log as logging
from heat.openstack.common import cfg 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') logger = logging.getLogger('heat.engine.wait_condition')
@ -38,15 +51,63 @@ class WaitConditionHandle(resource.Resource):
def __init__(self, name, json_snippet, stack): def __init__(self, name, json_snippet, stack):
super(WaitConditionHandle, self).__init__(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): def handle_create(self):
self.resource_id = '%s/stacks/%s/resources/%s' % \ # Create a keystone user so we can create a signed URL via FnGetRefId
(cfg.CONF.heat_waitcondition_server_url, user_id = self.keystone().create_stack_user(
self.stack.id, self.physical_resource_name())
self.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): def handle_update(self):
return self.UPDATE_REPLACE 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 = ( WAIT_STATUSES = (
WAITING, WAITING,
TIMEDOUT, TIMEDOUT,
@ -87,7 +148,7 @@ class WaitCondition(resource.Resource):
def _get_handle_resource_id(self): def _get_handle_resource_id(self):
if self.resource_id is None: if self.resource_id is None:
handle_url = self.properties['Handle'] 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 return self.resource_id
def _get_status_reason(self, handle): def _get_status_reason(self, handle):

View File

@ -17,17 +17,22 @@ import json
import logging import logging
import mox import mox
import sys import sys
import uuid
import time
import eventlet import eventlet
import nose import nose
import unittest import unittest
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from heat.tests import fakes
import heat.db as db_api import heat.db as db_api
from heat.engine import format from heat.engine import format
from heat.engine import parser from heat.engine import parser
from heat.engine.resources import wait_condition as wc from heat.engine.resources import wait_condition as wc
from heat.common import context from heat.common import context
from heat.common import config
from heat.openstack.common import cfg
logger = logging.getLogger('test_waitcondition') logger = logging.getLogger('test_waitcondition')
@ -52,9 +57,9 @@ test_template_waitcondition = '''
''' '''
@attr(tag=['unit', 'resource']) @attr(tag=['unit', 'resource', 'WaitCondition'])
@attr(speed='slow') @attr(speed='slow')
class stacksTest(unittest.TestCase): class WaitConditionTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.m = mox.Mox() self.m = mox.Mox()
self.m.StubOutWithMock(wc.WaitCondition, self.m.StubOutWithMock(wc.WaitCondition,
@ -63,6 +68,12 @@ class stacksTest(unittest.TestCase):
'_create_timeout') '_create_timeout')
self.m.StubOutWithMock(eventlet, 'sleep') 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): def tearDown(self):
self.m.UnsetStubs() self.m.UnsetStubs()
@ -90,6 +101,9 @@ class stacksTest(unittest.TestCase):
wc.WaitCondition._get_status_reason( wc.WaitCondition._get_status_reason(
mox.IgnoreArg()).AndReturn(('SUCCESS', 'woot toot')) mox.IgnoreArg()).AndReturn(('SUCCESS', 'woot toot'))
self.m.StubOutWithMock(wc.WaitConditionHandle, 'keystone')
wc.WaitConditionHandle.keystone().MultipleTimes().AndReturn(self.fc)
self.m.ReplayAll() self.m.ReplayAll()
stack.create() stack.create()
@ -118,6 +132,9 @@ class stacksTest(unittest.TestCase):
mox.IgnoreArg()).AndReturn(('WAITING', '')) mox.IgnoreArg()).AndReturn(('WAITING', ''))
eventlet.sleep(1).AndRaise(tmo) eventlet.sleep(1).AndRaise(tmo)
self.m.StubOutWithMock(wc.WaitConditionHandle, 'keystone')
wc.WaitConditionHandle.keystone().MultipleTimes().AndReturn(self.fc)
self.m.ReplayAll() self.m.ReplayAll()
stack.create() stack.create()
@ -133,7 +150,78 @@ class stacksTest(unittest.TestCase):
self.m.VerifyAll() self.m.VerifyAll()
# allows testing of the test directly
if __name__ == '__main__': @attr(tag=['unit', 'resource', 'WaitConditionHandle'])
sys.argv.append(__file__) @attr(speed='fast')
nose.main() 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()