From cbef4bef028df3d5868e98ae2c55e9dd88149ef0 Mon Sep 17 00:00:00 2001 From: Zane Bitter Date: Thu, 22 Nov 2012 11:06:57 +0100 Subject: [PATCH] ReST API: Add Events Change-Id: I716dc2ad1c9294a7a9df27fbb77e32926b1ba307 Signed-off-by: Zane Bitter --- docs/api.md | 63 +++++ heat/api/openstack/v1/__init__.py | 27 +- heat/api/openstack/v1/events.py | 132 +++++++++ heat/tests/test_api_openstack_v1.py | 402 ++++++++++++++++++++++++++++ 4 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 heat/api/openstack/v1/events.py diff --git a/docs/api.md b/docs/api.md index 385a6d1cfc..2fe04da1f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -223,3 +223,66 @@ Parameters: * `stack_name` The name of the stack to look up * `stack_id` The unique identifier of the stack to look up * `resource_name` The name of the resource in the template + +List Stack Events +----------------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/events +``` + +Parameters: + +* `tenant_id` The unique identifier of the tenant or account +* `stack_name` The name of the stack to look up +* `stack_id` The unique identifier of the stack to look up + +Find Stack Events by Name +------------------------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/events +``` + +Parameters: + +* `stack_name` The name of the stack to look up + +Result: + +``` +HTTP/1.1 302 Found +Location: http://heat.example.com:8004/v1/{tenant_id}/stacks/{stack_name}/{stack_id}/events +``` + +This is a shortcut to go directly to the list of stack events when only the stack name is known. + + +List Resource Events +-------------------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources/{resource_name}/events +``` + +Parameters: + +* `tenant_id` The unique identifier of the tenant or account +* `stack_name` The name of the stack to look up +* `stack_id` The unique identifier of the stack to look up +* `resource_name` The name of the resource in the template + +Get Event +--------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources/{resource_name}/events/{event_id} +``` + +Parameters: + +* `tenant_id` The unique identifier of the tenant or account +* `stack_name` The name of the stack to look up +* `stack_id` The unique identifier of the stack to look up +* `resource_name` The name of the resource in the template +* `event_id` The ID of the event diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py index f95f549d10..5600b51ac9 100644 --- a/heat/api/openstack/v1/__init__.py +++ b/heat/api/openstack/v1/__init__.py @@ -20,6 +20,7 @@ gettext.install('heat', unicode=1) from heat.api.openstack.v1 import stacks from heat.api.openstack.v1 import resources +from heat.api.openstack.v1 import events from heat.common import wsgi from heat.openstack.common import log as logging @@ -61,8 +62,10 @@ class API(wsgi.Router): stack_mapper.connect("stack_lookup", "/stacks/{stack_name}", action="lookup") + subpaths = ['resources', 'events'] + path = "{path:%s}" % '|'.join(subpaths) stack_mapper.connect("stack_lookup_subpath", - "/stacks/{stack_name}/{path:resources}", + "/stacks/{stack_name}/" + path, action="lookup", conditions={'method': 'GET'}) stack_mapper.connect("stack_show", @@ -106,4 +109,26 @@ class API(wsgi.Router): action="metadata", conditions={'method': 'GET'}) + # Events + events_resource = events.create_resource(conf) + with mapper.submapper(controller=events_resource, + path_prefix=stack_path) as ev_mapper: + + # Stack event collection + ev_mapper.connect("event_index_stack", + "/events", + action="index", + conditions={'method': 'GET'}) + # Resource event collection + ev_mapper.connect("event_index_resource", + "/resources/{resource_name}/events", + action="index", + conditions={'method': 'GET'}) + + # Event data + ev_mapper.connect("event_show", + "/resources/{resource_name}/events/{event_id}", + action="show", + conditions={'method': 'GET'}) + super(API, self).__init__(mapper) diff --git a/heat/api/openstack/v1/events.py b/heat/api/openstack/v1/events.py new file mode 100644 index 0000000000..545d53bb4c --- /dev/null +++ b/heat/api/openstack/v1/events.py @@ -0,0 +1,132 @@ +# 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 itertools +from webob import exc + +from heat.api.openstack.v1 import util +from heat.common import wsgi +from heat.engine import api as engine_api +from heat.engine import identifier +from heat.engine import rpcapi as engine_rpcapi +import heat.openstack.common.rpc.common as rpc_common +from heat.openstack.common.gettextutils import _ + + +summary_keys = [ + engine_api.EVENT_ID, + engine_api.EVENT_TIMESTAMP, + engine_api.EVENT_RES_NAME, + engine_api.EVENT_RES_STATUS, + engine_api.EVENT_RES_STATUS_DATA, + engine_api.EVENT_RES_PHYSICAL_ID, +] + + +def format_event(req, event, keys=None): + include_key = lambda k: k in keys if keys else True + + def transform(key, value): + if not include_key(key): + return + + if key == engine_api.EVENT_ID: + identity = identifier.EventIdentifier(**value) + yield ('id', identity.event_id) + yield ('links', [util.make_link(req, identity), + util.make_link(req, identity.resource(), + 'resource'), + util.make_link(req, identity.stack(), + 'stack')]) + elif (key == engine_api.EVENT_STACK_ID or + key == engine_api.EVENT_STACK_NAME): + return + else: + yield (key, value) + + return dict(itertools.chain.from_iterable( + transform(k, v) for k, v in event.items())) + + +class EventController(object): + """ + WSGI controller for Events in Heat v1 API + Implements the API actions + """ + + def __init__(self, options): + self.options = options + self.engine = engine_rpcapi.EngineAPI() + + def _event_list(self, req, identity, + filter_func=lambda e: True, detail=False): + try: + result = self.engine.list_events(req.context, + identity) + except rpc_common.RemoteError as ex: + return util.remote_error(ex) + + if 'events' not in result: + raise exc.HTTPInternalServerError() + ev_list = result['events'] + + keys = None if detail else summary_keys + + return [format_event(req, e, keys) for e in ev_list if filter_func(e)] + + @util.identified_stack + def index(self, req, identity, resource_name=None): + """ + Lists summary information for all resources + """ + + if resource_name is None: + events = self._event_list(req, identity) + else: + res_match = lambda e: e[engine_api.EVENT_RES_NAME] == resource_name + + events = self._event_list(req, identity, res_match) + if not events: + msg = _('No events found for resource %s') % resource_name + raise exc.HTTPNotFound(msg) + + return {'events': events} + + @util.identified_stack + def show(self, req, identity, resource_name, event_id): + """ + Gets detailed information for a stack + """ + + def event_match(ev): + identity = identifier.EventIdentifier(**ev[engine_api.EVENT_ID]) + return (ev[engine_api.EVENT_RES_NAME] == resource_name and + identity.event_id == event_id) + + events = self._event_list(req, identity, event_match, True) + if not events: + raise exc.HTTPNotFound(_('No event %s found') % event_id) + + return {'event': events[0]} + + +def create_resource(options): + """ + Events resource factory method. + """ + # TODO(zaneb) handle XML based on Content-type/Accepts + deserializer = wsgi.JSONRequestDeserializer() + serializer = wsgi.JSONResponseSerializer() + return wsgi.Resource(EventController(options), deserializer, serializer) diff --git a/heat/tests/test_api_openstack_v1.py b/heat/tests/test_api_openstack_v1.py index f94ca97a0b..2e42f59c3a 100644 --- a/heat/tests/test_api_openstack_v1.py +++ b/heat/tests/test_api_openstack_v1.py @@ -35,6 +35,7 @@ from heat.common.wsgi import Request import heat.api.openstack.v1.stacks as stacks import heat.api.openstack.v1.resources as resources +import heat.api.openstack.v1.events as events @attr(tag=['unit', 'api-openstack-v1']) @@ -1021,6 +1022,407 @@ class ResourceControllerTest(ControllerTest, unittest.TestCase): self.m.VerifyAll() +@attr(tag=['unit', 'api-openstack-v1', 'EventController']) +@attr(speed='fast') +class EventControllerTest(ControllerTest, unittest.TestCase): + ''' + Tests the API class which acts as the WSGI controller, + the endpoint processing API requests after they are routed + ''' + + def setUp(self): + # Create WSGI controller instance + class DummyConfig(): + bind_port = 8004 + cfgopts = DummyConfig() + self.controller = events.EventController(options=cfgopts) + + def test_resource_index(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev_identity = identifier.EventIdentifier(event_id=event_id, + **res_identity) + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events') + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + }, + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': 'SomeOtherResource', + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + result = self.controller.index(req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name) + + expected = { + 'events': [ + { + 'id': event_id, + 'links': [ + {'href': self._url(ev_identity), 'rel': 'self'}, + {'href': self._url(res_identity), 'rel': 'resource'}, + {'href': self._url(stack_identity), 'rel': 'stack'}, + ], + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_time': u'2012-07-23T13:05:39Z', + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + } + ] + } + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_stack_index(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev_identity = identifier.EventIdentifier(event_id=event_id, + **res_identity) + + req = self._get(stack_identity._tenant_path() + '/events') + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + result = self.controller.index(req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id) + + expected = { + 'events': [ + { + 'id': event_id, + 'links': [ + {'href': self._url(ev_identity), 'rel': 'self'}, + {'href': self._url(res_identity), 'rel': 'resource'}, + {'href': self._url(stack_identity), 'rel': 'stack'}, + ], + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_time': u'2012-07-23T13:05:39Z', + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + } + ] + } + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_index_stack_nonexist(self): + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wibble', '6') + + req = self._get(stack_identity._tenant_path() + '/events') + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndRaise(rpc_common.RemoteError("AttributeError")) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.index, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id) + self.m.VerifyAll() + + def test_index_resource_nonexist(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev_identity = identifier.EventIdentifier(event_id=event_id, + **res_identity) + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events') + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': 'SomeOtherResource', + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.index, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name) + self.m.VerifyAll() + + def test_show(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev1_identity = identifier.EventIdentifier(event_id='41', + **res_identity) + ev_identity = identifier.EventIdentifier(event_id=event_id, + **res_identity) + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events/' + event_id) + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev1_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + }, + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:06:00Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'CREATE_COMPLETE', + u'physical_resource_id': + u'a3455d8c-9f88-404d-a85b-5315293e67de', + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + result = self.controller.show(req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name, + event_id=event_id) + + expected = { + 'event': { + 'id': event_id, + 'links': [ + {'href': self._url(ev_identity), 'rel': 'self'}, + {'href': self._url(res_identity), 'rel': 'resource'}, + {'href': self._url(stack_identity), 'rel': 'stack'}, + ], + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_time': u'2012-07-23T13:06:00Z', + u'resource_status': u'CREATE_COMPLETE', + u'physical_resource_id': + u'a3455d8c-9f88-404d-a85b-5315293e67de', + u'resource_type': u'AWS::EC2::Instance', + u'resource_properties': {u'UserData': u'blah'}, + } + } + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_show_nonexist(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev_identity = identifier.EventIdentifier(event_id='41', + **res_identity) + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events/' + event_id) + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': res_name, + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name, event_id=event_id) + self.m.VerifyAll() + + def test_show_bad_resource(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(resource_name=res_name, + **stack_identity) + ev_identity = identifier.EventIdentifier(event_id='41', + **res_identity) + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events/' + event_id) + + engine_resp = {u'events': [ + { + u'stack_name': u'wordpress', + u'event_time': u'2012-07-23T13:05:39Z', + u'stack_identity': dict(stack_identity), + u'logical_resource_id': 'SomeOtherResourceName', + u'resource_status_reason': u'state changed', + u'event_identity': dict(ev_identity), + u'resource_status': u'IN_PROGRESS', + u'physical_resource_id': None, + u'resource_properties': {u'UserData': u'blah'}, + u'resource_type': u'AWS::EC2::Instance', + } + ]} + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name, event_id=event_id) + self.m.VerifyAll() + + def test_show_stack_nonexist(self): + event_id = '42' + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wibble', '6') + + req = self._get(stack_identity._tenant_path() + + '/resources/' + res_name + '/events/' + event_id) + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_events', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndRaise(rpc_common.RemoteError("AttributeError")) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name, event_id=event_id) + self.m.VerifyAll() + + if __name__ == '__main__': sys.argv.append(__file__) nose.main()