]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
Add an API for passing a signal through to a resource
authorAngus Salkeld <asalkeld@redhat.com>
Thu, 25 Jul 2013 00:36:50 +0000 (10:36 +1000)
committerAngus Salkeld <asalkeld@redhat.com>
Thu, 25 Jul 2013 00:36:50 +0000 (10:36 +1000)
Reuse the /waitcondition functionality to provide a
/signal pathway.
a signal could be a waitcondition or an alarm.

blueprint watch-ceilometer
Change-Id: Ia9da222b8072ee0dc65bf137e7d3eb7fa4d944f5

heat/api/cfn/v1/__init__.py
heat/api/cfn/v1/signal.py [moved from heat/api/cfn/v1/waitcondition.py with 72% similarity]
heat/engine/resource.py
heat/engine/service.py
heat/engine/signal_responder.py
heat/rpc/client.py
heat/tests/generic_resource.py
heat/tests/test_engine_service.py
heat/tests/test_rpc_client.py
heat/tests/test_signal.py [new file with mode: 0644]

index 2654e0742f79a66d5a5b47ba67bf0cc76d9a4ab4..d00d7f130254dac34abde7cee68f0098c7ac099e 100644 (file)
@@ -18,7 +18,7 @@ import routes
 from webob import Request
 
 from heat.api.cfn.v1 import stacks
-from heat.api.cfn.v1 import waitcondition
+from heat.api.cfn.v1 import signal
 from heat.common import wsgi
 from heat.openstack.common import log as logging
 
@@ -71,13 +71,18 @@ class API(wsgi.Router):
 
         mapper.connect("/", controller=stacks_resource, action="index")
 
-        # Add controller which handles waitcondition notifications
+        # Add controller which handles signals on resources like:
+        # waitconditions and alarms.
         # This is not part of the main CFN API spec, hence handle it
         # separately via a different path
-        waitcondition_controller = waitcondition.create_resource(conf)
+        signal_controller = signal.create_resource(conf)
         mapper.connect('/waitcondition/{arn:.*}',
-                       controller=waitcondition_controller,
+                       controller=signal_controller,
                        action='update_waitcondition',
                        conditions=dict(method=['PUT']))
+        mapper.connect('/signal/{arn:.*}',
+                       controller=signal_controller,
+                       action='signal',
+                       conditions=dict(method=['POST']))
 
         super(API, self).__init__(mapper)
similarity index 72%
rename from heat/api/cfn/v1/waitcondition.py
rename to heat/api/cfn/v1/signal.py
index 1be0d6c45a13cf321fcb87f8e117f24dc624e16d..47ad866be0ae399cd35181e7fe96840868c5189d 100644 (file)
@@ -20,7 +20,7 @@ from heat.api.aws import exception
 import heat.openstack.common.rpc.common as rpc_common
 
 
-class WaitConditionController(object):
+class SignalController(object):
     def __init__(self, options):
         self.options = options
         self.engine = rpc_client.EngineClient()
@@ -39,10 +39,22 @@ class WaitConditionController(object):
 
         return {'resource': identity.resource_name, 'metadata': md}
 
+    def signal(self, req, body, arn):
+        con = req.context
+        identity = identifier.ResourceIdentifier.from_arn(arn)
+        try:
+            md = self.engine.resource_signal(
+                con,
+                stack_identity=dict(identity.stack()),
+                resource_name=identity.resource_name,
+                details=body)
+        except rpc_common.RemoteError as ex:
+            return exception.map_remote_error(ex)
+
 
 def create_resource(options):
     """
-    Stacks resource factory method.
+    Signal resource factory method.
     """
     deserializer = wsgi.JSONRequestDeserializer()
-    return wsgi.Resource(WaitConditionController(options), deserializer)
+    return wsgi.Resource(SignalController(options), deserializer)
index 59f5ef05012e35491f7e8c4f15fdeed973e38d27..8f41392de2fa5b3c3542d638f1ab23387e81c173 100644 (file)
@@ -676,6 +676,29 @@ class Resource(object):
         '''
         return base64.b64encode(data)
 
+    def signal(self, details=None):
+        '''
+        signal the resource. Subclasses should provide a handle_signal() method
+        to implement the signal, the base-class raise an exception if no
+        handler is implemented.
+        '''
+        if self.action in (self.SUSPEND, self.DELETE):
+            raise exception.ResourceFailure(Exception(
+                'Can not send a signal to a Resource whilst actioning a %s' %
+                self.action))
+
+        if not callable(getattr(self, 'handle_signal', None)):
+            raise exception.ResourceFailure(Exception(
+                'Resource %s is not able to receive a signal' % str(self)))
+
+        try:
+            self._add_event('signal', self.status, details)
+            self.handle_signal(details)
+        except Exception as ex:
+            logger.exception('signal %s : %s' % (str(self), str(ex)))
+            failure = exception.ResourceFailure(ex)
+            raise failure
+
     def handle_update(self, json_snippet=None, tmpl_diff=None, prop_diff=None):
         raise UpdateReplace(self.name)
 
index 66797fceec3ed83f76f2faf7b1ea660f7dbcd7e3..e500f42e11199bca6ef01dda0f549e6d165acd67 100644 (file)
@@ -465,6 +465,29 @@ class EngineService(service.Service):
 
         return api.format_stack_resource(stack[resource_name])
 
+    @request_context
+    def resource_signal(self, cnxt, stack_identity, resource_name, details):
+        s = self._get_stack(cnxt, stack_identity)
+
+        # This is not "nice" converting to the stored context here,
+        # but this happens because the keystone user associated with the
+        # signal doesn't have permission to read the secret key of
+        # the user associated with the cfn-credentials file
+        user_creds = db_api.user_creds_get(s.user_creds_id)
+        stack_context = context.RequestContext.from_dict(user_creds)
+        stack = parser.Stack.load(stack_context, stack=s)
+
+        if resource_name not in stack:
+            raise exception.ResourceNotFound(resource_name=resource_name,
+                                             stack_name=stack.name)
+
+        resource = stack[resource_name]
+        if resource.id is None:
+            raise exception.ResourceNotAvailable(resource_name=resource_name)
+
+        if callable(stack[resource_name].signal):
+            stack[resource_name].signal(details)
+
     @request_context
     def find_physical_resource(self, cnxt, physical_resource_id):
         """
index 10a28457e45e3bd84c68c4dcc55db6c5a41d020f..2bf08a18fa1948d674477789e4e7b3dd59468669 100644 (file)
@@ -31,6 +31,8 @@ SIGNAL_TYPES = (
 ) = (
     '/waitcondition', '/signal'
 )
+SIGNAL_VERB = {WAITCONDITION: 'PUT',
+               SIGNAL: 'POST'}
 
 
 class SignalResponder(resource.Resource):
@@ -75,7 +77,7 @@ class SignalResponder(resource.Resource):
         # ensure the actual URL contains the quoted version...
         unquoted_path = urllib.unquote(host_url.path + path)
         request = {'host': host_url.netloc.lower(),
-                   'verb': 'PUT',
+                   'verb': SIGNAL_VERB[signal_type],
                    'path': unquoted_path,
                    'params': {'SignatureMethod': 'HmacSHA256',
                               'SignatureVersion': '2',
index 214ce0ce2e9c1f603e68bafa4e910e8b09aff26e..dba3c4ff87b05c10d21c70f33b8ffe88e6ad0b8f 100644 (file)
@@ -230,6 +230,19 @@ class EngineClient(heat.openstack.common.rpc.proxy.RpcProxy):
                                              resource_name=resource_name,
                                              metadata=metadata))
 
+    def resource_signal(self, ctxt, stack_identity, resource_name, details):
+        """
+        Generate an alarm on the resource.
+        :param ctxt: RPC context.
+        :param stack_identity: Name of the stack.
+        :param resource_name: the Resource.
+        :param details: the details of the signal.
+        """
+        return self.call(ctxt, self.make_msg('resource_signal',
+                                             stack_identity=stack_identity,
+                                             resource_name=resource_name,
+                                             details=details))
+
     def create_watch_data(self, ctxt, watch_name, stats_data):
         '''
         This could be used by CloudWatch and WaitConditions
index 26a7eec60f695809baaf3fce79ff40403b8d6dea..d519e6fe240ba3be898a94d30f2a191ccac57efc 100644 (file)
@@ -13,6 +13,7 @@
 #    under the License.
 
 from heat.engine import resource
+from heat.engine import signal_responder
 
 from heat.openstack.common import log as logging
 
@@ -50,3 +51,16 @@ class ResourceWithProps(GenericResource):
 class ResourceWithRequiredProps(GenericResource):
         properties_schema = {'Foo': {'Type': 'String',
                                      'Required': True}}
+
+
+class SignalResource(signal_responder.SignalResponder):
+    properties_schema = {}
+    attributes_schema = {'AlarmUrl': 'Get a signed webhook'}
+
+    def handle_signal(self, details=None):
+        logger.warning('Signaled resource (Type "%s") %s' % (self.type(),
+                                                             details))
+
+    def _resolve_attribute(self, name):
+        if name == 'AlarmUrl' and self.resource_id is not None:
+            return unicode(self._get_signed_url())
index 0ef899815d8ef0c8deeedd4e8bdf0d7185099f60..a32f29436d4ff6befec1f1fd697503ee662f39c2 100644 (file)
@@ -33,6 +33,7 @@ from heat.engine import parser
 from heat.engine import service
 from heat.engine.properties import Properties
 from heat.engine.resources import instance as instances
+from heat.engine import resource as rsrs
 from heat.engine import watchrule
 from heat.openstack.common import threadgroup
 from heat.tests.common import HeatTestCase
@@ -89,6 +90,24 @@ alarm_template = '''
 }
 '''
 
+policy_template = '''
+{
+  "AWSTemplateFormatVersion" : "2010-09-09",
+  "Description" : "alarming",
+  "Resources" : {
+    "WebServerScaleDownPolicy" : {
+      "Type" : "AWS::AutoScaling::ScalingPolicy",
+      "Properties" : {
+        "AdjustmentType" : "ChangeInCapacity",
+        "AutoScalingGroupName" : "",
+        "Cooldown" : "60",
+        "ScalingAdjustment" : "-1"
+      }
+    }
+  }
+}
+'''
+
 
 def create_context(mocks, user='stacks_test_user',
                    tenant_id='test_admin', password='stacks_test_password'):
@@ -110,8 +129,8 @@ def get_wordpress_stack(stack_name, ctx):
     return stack
 
 
-def get_alarm_stack(stack_name, ctx):
-    t = template_format.parse(alarm_template)
+def get_stack(stack_name, ctx, template):
+    t = template_format.parse(template)
     template = parser.Template(t)
     stack = parser.Stack(ctx, stack_name, template)
     return stack
@@ -1072,6 +1091,64 @@ class StackServiceTest(HeatTestCase):
 
         self.m.VerifyAll()
 
+    def test_signal_reception(self):
+        stack = get_stack('signal_reception',
+                          self.ctx,
+                          policy_template)
+        self.stack = stack
+        self.m.ReplayAll()
+        stack.store()
+        stack.create()
+        test_data = {'food': 'yum'}
+
+        self.m.StubOutWithMock(service.EngineService, '_get_stack')
+        s = db_api.stack_get(self.ctx, self.stack.id)
+        service.EngineService._get_stack(self.ctx,
+                                         self.stack.identifier()).AndReturn(s)
+
+        self.m.StubOutWithMock(db_api, 'user_creds_get')
+        db_api.user_creds_get(mox.IgnoreArg()).MultipleTimes().AndReturn(
+            self.ctx.to_dict())
+
+        self.m.StubOutWithMock(rsrs.Resource, 'signal')
+        rsrs.Resource.signal(mox.IgnoreArg()).AndReturn(None)
+        self.m.ReplayAll()
+
+        result = self.eng.resource_signal(self.ctx,
+                                          dict(self.stack.identifier()),
+                                          'WebServerScaleDownPolicy',
+                                          test_data)
+        self.m.VerifyAll()
+        self.stack.delete()
+
+    def test_signal_reception_no_resource(self):
+        stack = get_stack('signal_reception_no_resource',
+                          self.ctx,
+                          policy_template)
+        self.stack = stack
+        self.m.ReplayAll()
+        stack.store()
+        stack.create()
+        test_data = {'food': 'yum'}
+
+        self.m.StubOutWithMock(service.EngineService, '_get_stack')
+        s = db_api.stack_get(self.ctx, self.stack.id)
+        service.EngineService._get_stack(self.ctx,
+                                         self.stack.identifier()).AndReturn(s)
+
+        self.m.StubOutWithMock(db_api, 'user_creds_get')
+        db_api.user_creds_get(mox.IgnoreArg()).MultipleTimes().AndReturn(
+            self.ctx.to_dict())
+        self.m.ReplayAll()
+
+        self.assertRaises(exception.ResourceNotFound,
+                          self.eng.resource_signal, self.ctx,
+                          dict(self.stack.identifier()),
+                          'resource_does_not_exist',
+                          test_data)
+        self.m.VerifyAll()
+        self.stack.delete()
+
     @stack_context('service_metadata_test_stack')
     def test_metadata(self):
         test_metadata = {'foo': 'bar', 'baz': 'quux', 'blarg': 'wibble'}
@@ -1136,8 +1213,9 @@ class StackServiceTest(HeatTestCase):
         self.assertEqual([], self.eng.stg[self.stack.id].threads)
 
     def test_periodic_watch_task_created(self):
-        stack = get_alarm_stack('period_watch_task_created',
-                                create_context(self.m))
+        stack = get_stack('period_watch_task_created',
+                          create_context(self.m),
+                          alarm_template)
         self.stack = stack
         self.m.ReplayAll()
         stack.store()
index ccfd5238e596663c9221742f0c726cc384b732ca..3b25f7fbda9850592ac4d13a60ab75218e38e021 100644 (file)
@@ -153,6 +153,12 @@ class EngineRpcAPITestCase(testtools.TestCase):
                               resource_name='LogicalResourceId',
                               metadata={u'wordpress': []})
 
+    def test_resource_signal(self):
+        self._test_engine_api('resource_signal', 'call',
+                              stack_identity=self.identity,
+                              resource_name='LogicalResourceId',
+                              details={u'wordpress': []})
+
     def test_create_watch_data(self):
         self._test_engine_api('create_watch_data', 'call',
                               watch_name='watch1',
diff --git a/heat/tests/test_signal.py b/heat/tests/test_signal.py
new file mode 100644 (file)
index 0000000..0fd73ee
--- /dev/null
@@ -0,0 +1,212 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+#    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 datetime
+import uuid
+
+from oslo.config import cfg
+
+from heat.tests import generic_resource
+from heat.tests import fakes
+from heat.tests.common import HeatTestCase
+from heat.tests.utils import stack_delete_after
+from heat.tests.utils import setup_dummy_db
+
+from heat.common import context
+from heat.common import exception
+from heat.common import template_format
+
+from heat.engine import parser
+from heat.engine import resource
+from heat.engine import signal_responder as sr
+
+
+test_template_signal = '''
+{
+  "AWSTemplateFormatVersion" : "2010-09-09",
+  "Description" : "Just a test.",
+  "Parameters" : {},
+  "Resources" : {
+    "signal_handler" : {"Type" : "SignalResourceType"},
+    "resource_X" : {"Type" : "GenericResourceType"}
+  },
+  "Outputs": {
+    "signed_url": {"Fn::GetAtt": ["signal_handler", "AlarmUrl"]}
+  }
+}
+'''
+
+
+class UUIDStub(object):
+    def __init__(self, value):
+        self.value = value
+
+    def __enter__(self):
+        self.uuid4 = uuid.uuid4
+        uuid_stub = lambda: self.value
+        uuid.uuid4 = uuid_stub
+
+    def __exit__(self, *exc_info):
+        uuid.uuid4 = self.uuid4
+
+
+class SignalTest(HeatTestCase):
+
+    def setUp(self):
+        super(SignalTest, self).setUp()
+        setup_dummy_db()
+
+        resource._register_class('SignalResourceType',
+                                 generic_resource.SignalResource)
+        resource._register_class('GenericResourceType',
+                                 generic_resource.GenericResource)
+
+        cfg.CONF.set_default('heat_waitcondition_server_url',
+                             'http://127.0.0.1:8000/v1/waitcondition')
+
+        self.stack_id = 'STACKABCD1234'
+        self.fc = fakes.FakeKeystoneClient()
+
+    # Note tests creating a stack should be decorated with @stack_delete_after
+    # to ensure the stack is properly cleaned up
+    def create_stack(self, stack_name='test_stack', stub=True):
+        temp = template_format.parse(test_template_signal)
+        template = parser.Template(temp)
+        ctx = context.get_admin_context()
+        ctx.tenant_id = 'test_tenant'
+        stack = parser.Stack(ctx, stack_name, template,
+                             disable_rollback=True)
+
+        # Stub out the stack ID so we have a known value
+        with UUIDStub(self.stack_id):
+            stack.store()
+
+        if stub:
+            self.m.StubOutWithMock(sr.SignalResponder, 'keystone')
+            sr.SignalResponder.keystone().MultipleTimes().AndReturn(
+                self.fc)
+        return stack
+
+    @stack_delete_after
+    def test_FnGetAtt_Alarm_Url(self):
+        self.stack = self.create_stack()
+
+        self.m.ReplayAll()
+        self.stack.create()
+
+        rsrc = self.stack.resources['signal_handler']
+        created_time = datetime.datetime(2012, 11, 29, 13, 49, 37)
+        rsrc.created_time = created_time
+        self.assertEqual(rsrc.state, (rsrc.CREATE, rsrc.COMPLETE))
+
+        expected_url = "".join([
+            'http://127.0.0.1:8000/v1/signal/',
+            'arn%3Aopenstack%3Aheat%3A%3Atest_tenant%3Astacks%2F',
+            'test_stack%2FSTACKABCD1234%2Fresources%2F',
+            'signal_handler?',
+            'Timestamp=2012-11-29T13%3A49%3A37Z&',
+            'SignatureMethod=HmacSHA256&',
+            'AWSAccessKeyId=4567&',
+            'SignatureVersion=2&',
+            'Signature=',
+            'MJIFh7LKCpVlK6pCxe2WfYrRsfO7FU3Wt%2BzQFo2rYSY%3D'])
+
+        self.assertEqual(expected_url, rsrc.FnGetAtt('AlarmUrl'))
+        self.m.VerifyAll()
+
+    @stack_delete_after
+    def test_signal(self):
+        test_d = {'Data': 'foo', 'Reason': 'bar',
+                  'Status': 'SUCCESS', 'UniqueId': '123'}
+
+        self.stack = self.create_stack()
+
+        # to confirm we get a call to handle_signal
+        self.m.StubOutWithMock(generic_resource.SignalResource,
+                               'handle_signal')
+        generic_resource.SignalResource.handle_signal(test_d).AndReturn(None)
+
+        self.m.ReplayAll()
+        self.stack.create()
+
+        rsrc = self.stack.resources['signal_handler']
+        self.assertEqual(rsrc.state, (rsrc.CREATE, rsrc.COMPLETE))
+
+        rsrc.signal(details=test_d)
+
+        self.m.VerifyAll()
+
+    @stack_delete_after
+    def test_signal_wrong_resource(self):
+        # assert that we get the correct exception when calling a
+        # resource.signal() that does not have a handle_signal()
+        self.stack = self.create_stack()
+
+        self.m.ReplayAll()
+        self.stack.create()
+
+        rsrc = self.stack.resources['resource_X']
+        self.assertEqual(rsrc.state, (rsrc.CREATE, rsrc.COMPLETE))
+
+        err_metadata = {'Data': 'foo', 'Status': 'SUCCESS', 'UniqueId': '123'}
+        self.assertRaises(exception.ResourceFailure, rsrc.signal,
+                          details=err_metadata)
+
+        self.m.VerifyAll()
+
+    @stack_delete_after
+    def test_signal_reception_wrong_state(self):
+        # assert that we get the correct exception when calling a
+        # resource.signal() that is in having a destructive action.
+        self.stack = self.create_stack()
+
+        self.m.ReplayAll()
+        self.stack.create()
+
+        rsrc = self.stack.resources['signal_handler']
+        self.assertEqual(rsrc.state, (rsrc.CREATE, rsrc.COMPLETE))
+        # manually override the action to DELETE
+        rsrc.action = rsrc.DELETE
+
+        err_metadata = {'Data': 'foo', 'Status': 'SUCCESS', 'UniqueId': '123'}
+        self.assertRaises(exception.ResourceFailure, rsrc.signal,
+                          details=err_metadata)
+
+        self.m.VerifyAll()
+
+    @stack_delete_after
+    def test_signal_reception_failed_call(self):
+        # assert that we get the correct exception from resource.signal()
+        # when resource.handle_signal() raises an exception.
+        self.stack = self.create_stack()
+
+        test_d = {'Data': 'foo', 'Reason': 'bar',
+                  'Status': 'SUCCESS', 'UniqueId': '123'}
+
+        # to confirm we get a call to handle_signal
+        self.m.StubOutWithMock(generic_resource.SignalResource,
+                               'handle_signal')
+        generic_resource.SignalResource.handle_signal(test_d).AndRaise(
+            ValueError)
+
+        self.m.ReplayAll()
+        self.stack.create()
+
+        rsrc = self.stack.resources['signal_handler']
+        self.assertEqual(rsrc.state, (rsrc.CREATE, rsrc.COMPLETE))
+
+        self.assertRaises(exception.ResourceFailure,
+                          rsrc.signal, details=test_d)
+
+        self.m.VerifyAll()