From d892d8166cbb002effc9518793d114406ae59112 Mon Sep 17 00:00:00 2001 From: Zane Bitter Date: Mon, 12 Nov 2012 17:42:37 +0100 Subject: [PATCH] ReST API: Add API for Resources Change-Id: I860349d03a2d7d034c600a129aead59964930b02 Signed-off-by: Zane Bitter --- docs/api.md | 42 +++++ heat/api/openstack/v1/__init__.py | 23 +++ heat/api/openstack/v1/resources.py | 111 +++++++++++++ heat/api/openstack/v1/util.py | 16 ++ heat/tests/test_api_openstack_v1.py | 245 ++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 heat/api/openstack/v1/resources.py diff --git a/docs/api.md b/docs/api.md index 44305a7b..dc4262e7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -86,6 +86,7 @@ GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/template 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 @@ -161,3 +162,44 @@ Parameters: * `template_url` The URL of the template to validate * `template` A JSON template to validate - this takes precendence over the `template_url` if both are supplied. * `keyn`, `valuen` User-defined parameters to pass to the Template + +List Stack Resources +-------------------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources +``` + +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 + +Get Resource +------------ + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources/{resource_name} +``` + +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 Resource Metadata +--------------------- + +``` +GET /v1/{tenant_id}/stacks/{stack_name}/{stack_id}/resources/{resource_name}/metadata +``` + +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 diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py index c138d50e..4a02ee63 100644 --- a/heat/api/openstack/v1/__init__.py +++ b/heat/api/openstack/v1/__init__.py @@ -22,6 +22,7 @@ import gettext gettext.install('heat', unicode=1) from heat.api.openstack.v1 import stacks +from heat.api.openstack.v1 import resources from heat.common import wsgi from webob import Request @@ -87,4 +88,26 @@ class API(wsgi.Router): action="delete", conditions={'method': 'DELETE'}) + # Resources + resources_resource = resources.create_resource(conf) + stack_path = "/{tenant_id}/stacks/{stack_name}/{stack_id}" + with mapper.submapper(controller=resources_resource, + path_prefix=stack_path) as res_mapper: + + # Resource collection + res_mapper.connect("resource_index", + "/resources", + action="index", + conditions={'method': 'GET'}) + + # Resource data + res_mapper.connect("resource_show", + "/resources/{resource_name}", + action="show", + conditions={'method': 'GET'}) + res_mapper.connect("resource_metadata_show", + "/resources/{resource_name}/metadata", + action="metadata", + conditions={'method': 'GET'}) + super(API, self).__init__(mapper) diff --git a/heat/api/openstack/v1/resources.py b/heat/api/openstack/v1/resources.py new file mode 100644 index 00000000..0cbaab2f --- /dev/null +++ b/heat/api/openstack/v1/resources.py @@ -0,0 +1,111 @@ +# 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 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 + + +def format_resource(req, stack, keys=[]): + 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.RES_ID: + identity = identifier.HeatIdentifier(**value) + yield ('links', [util.make_link(req, identity), + util.make_link(req, identity.stack(), 'stack')]) + elif (key == engine_api.RES_STACK_NAME or + key == engine_api.RES_STACK_ID): + return + elif (key == engine_api.RES_METADATA): + return + else: + yield (key, value) + + return dict(itertools.chain.from_iterable( + transform(k, v) for k, v in stack.items())) + + +class ResourceController(object): + """ + WSGI controller for Resources in Heat v1 API + Implements the API actions + """ + + def __init__(self, options): + self.options = options + self.engine = engine_rpcapi.EngineAPI() + + @util.identified_stack + def index(self, req, identity): + """ + Lists summary information for all resources + """ + + try: + res_list = self.engine.list_stack_resources(req.context, + identity) + except rpc_common.RemoteError as ex: + return util.remote_error(ex) + + return {'resources': [format_resource(req, res) for res in res_list]} + + @util.identified_stack + def show(self, req, identity, resource_name): + """ + Gets detailed information for a stack + """ + + try: + res = self.engine.describe_stack_resource(req.context, + identity, + resource_name) + except rpc_common.RemoteError as ex: + return util.remote_error(ex) + + return {'resource': format_resource(req, res)} + + @util.identified_stack + def metadata(self, req, identity, resource_name): + """ + Gets detailed information for a stack + """ + + try: + res = self.engine.describe_stack_resource(req.context, + identity, + resource_name) + except rpc_common.RemoteError as ex: + return util.remote_error(ex) + + return {engine_api.RES_METADATA: res[engine_api.RES_METADATA]} + + +def create_resource(options): + """ + Resources resource factory method. + """ + # TODO(zaneb) handle XML based on Content-type/Accepts + deserializer = wsgi.JSONRequestDeserializer() + serializer = wsgi.JSONResponseSerializer() + return wsgi.Resource(ResourceController(options), deserializer, serializer) diff --git a/heat/api/openstack/v1/util.py b/heat/api/openstack/v1/util.py index 26de4c01..7d367d95 100644 --- a/heat/api/openstack/v1/util.py +++ b/heat/api/openstack/v1/util.py @@ -48,6 +48,22 @@ def identified_stack(handler): return handle_stack_method +def identified_resource(handler): + ''' + Decorator for a handler method that passes a resource identifier in place + of the various path components. + ''' + @identified_stack + @wraps(handler) + def handle_stack_method(controller, stack_identity, + resource_name, **kwargs): + resource_identity = identifier.ResourceIdentifier(stack_identity, + resource_name) + return handler(controller, req, dict(resource_identity), **kwargs) + + return handle_stack_method + + def make_url(req, identity): '''Return the URL for the supplied identity dictionary.''' try: diff --git a/heat/tests/test_api_openstack_v1.py b/heat/tests/test_api_openstack_v1.py index 8d5c7eda..ae5a2c15 100644 --- a/heat/tests/test_api_openstack_v1.py +++ b/heat/tests/test_api_openstack_v1.py @@ -34,6 +34,7 @@ import heat.openstack.common.rpc.common as rpc_common from heat.common.wsgi import Request import heat.api.openstack.v1.stacks as stacks +import heat.api.openstack.v1.resources as resources @attr(tag=['unit', 'api-openstack-v1']) @@ -733,6 +734,250 @@ class StackControllerTest(ControllerTest, unittest.TestCase): self.m.VerifyAll() +@attr(tag=['unit', 'api-openstack-v1', 'ResourceController']) +@attr(speed='fast') +class ResourceControllerTest(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 = resources.ResourceController(options=cfgopts) + + def test_index(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + res_identity = identifier.ResourceIdentifier(stack_identity, + res_name) + + req = self._get(stack_identity._tenant_path() + '/resources') + + engine_resp = [ + { + u'resource_identity': dict(res_identity), + u'stack_name': stack_identity.stack_name, + u'logical_resource_id': res_name, + u'resource_status_reason': None, + u'updated_time': u'2012-07-23T13:06:00Z', + u'stack_identity': stack_identity, + u'resource_status': u'CREATE_COMPLETE', + u'physical_resource_id': + u'a3455d8c-9f88-404d-a85b-5315293e67de', + u'resource_type': u'AWS::EC2::Instance', + } + ] + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_stack_resources', + '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 = { + 'resources': [ + { + 'links': [ + {'href': self._url(res_identity), 'rel': 'self'}, + {'href': self._url(stack_identity), 'rel': 'stack'}, + ], + u'logical_resource_id': res_name, + u'resource_status_reason': None, + u'updated_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', + } + ] + } + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_index_nonexist(self): + stack_identity = identifier.HeatIdentifier(self.tenant, + 'rubbish', '1') + + req = self._get(stack_identity._tenant_path() + '/resources') + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'list_stack_resources', + '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_show(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(stack_identity, + res_name) + + req = self._get(stack_identity._tenant_path()) + + engine_resp = { + u'description': u'', + u'resource_identity': dict(res_identity), + u'stack_name': stack_identity.stack_name, + u'logical_resource_id': res_name, + u'resource_status_reason': None, + u'updated_time': u'2012-07-23T13:06:00Z', + u'stack_identity': dict(stack_identity), + u'resource_status': u'CREATE_COMPLETE', + u'physical_resource_id': + u'a3455d8c-9f88-404d-a85b-5315293e67de', + u'resource_type': u'AWS::EC2::Instance', + u'metadata': {u'ensureRunning': u'true'} + } + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'describe_stack_resource', + 'args': {'stack_identity': stack_identity, + 'resource_name': res_name}, + '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) + + expected = { + 'resource': { + 'links': [ + {'href': self._url(res_identity), 'rel': 'self'}, + {'href': self._url(stack_identity), 'rel': 'stack'}, + ], + u'description': u'', + u'logical_resource_id': res_name, + u'resource_status_reason': None, + u'updated_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', + } + } + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_show_nonexist(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'rubbish', '1') + res_identity = identifier.ResourceIdentifier(stack_identity, + res_name) + + req = self._get(res_identity._tenant_path()) + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'describe_stack_resource', + 'args': {'stack_identity': stack_identity, + 'resource_name': res_name}, + '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) + self.m.VerifyAll() + + def test_show(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '6') + res_identity = identifier.ResourceIdentifier(stack_identity, + res_name) + + req = self._get(stack_identity._tenant_path()) + + engine_resp = { + u'description': u'', + u'resource_identity': dict(res_identity), + u'stack_name': stack_identity.stack_name, + u'logical_resource_id': res_name, + u'resource_status_reason': None, + u'updated_time': u'2012-07-23T13:06:00Z', + u'stack_identity': dict(stack_identity), + u'resource_status': u'CREATE_COMPLETE', + u'physical_resource_id': + u'a3455d8c-9f88-404d-a85b-5315293e67de', + u'resource_type': u'AWS::EC2::Instance', + u'metadata': {u'ensureRunning': u'true'} + } + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'describe_stack_resource', + 'args': {'stack_identity': stack_identity, + 'resource_name': res_name}, + 'version': self.api_version}, + None).AndReturn(engine_resp) + self.m.ReplayAll() + + result = self.controller.metadata(req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name) + + expected = {'metadata': {u'ensureRunning': u'true'}} + + self.assertEqual(result, expected) + self.m.VerifyAll() + + def test_metadata_show_nonexist(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'rubbish', '1') + res_identity = identifier.ResourceIdentifier(stack_identity, + res_name) + + req = self._get(res_identity._tenant_path() + '/metadata') + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'method': 'describe_stack_resource', + 'args': {'stack_identity': stack_identity, + 'resource_name': res_name}, + 'version': self.api_version}, + None).AndRaise(rpc_common.RemoteError("AttributeError")) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.metadata, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + resource_name=res_name) + self.m.VerifyAll() + + if __name__ == '__main__': sys.argv.append(__file__) nose.main() -- 2.45.2