From 243813f8fbbad084f6c07bdc423cf3631c846576 Mon Sep 17 00:00:00 2001 From: Steven Hardy Date: Fri, 10 May 2013 18:29:04 +0100 Subject: [PATCH] api : Add ReST actions POST method Initial support for an actions subpath, which will be the access-point for non-lifecycle operations on stacks, e.g suspend/resume This adds support for a POST method for /actions subpath, so that requests for specific actions may be made, using a similar interface to that provided by nova for the admin actions extension. So a body of {'suspend': None} will suspend the stack, see http://api.openstack.org/api-ref.html blueprint: stack-suspend-resume Change-Id: I180345c818cbfa38ac8c17caba2c0f64adabf6be --- heat/api/openstack/v1/__init__.py | 17 ++- heat/api/openstack/v1/actions.py | 69 ++++++++++++ heat/tests/test_api_openstack_v1.py | 164 ++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 heat/api/openstack/v1/actions.py diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py index f9d0eb1b..2f65ad81 100644 --- a/heat/api/openstack/v1/__init__.py +++ b/heat/api/openstack/v1/__init__.py @@ -21,6 +21,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.api.openstack.v1 import actions from heat.common import wsgi from heat.openstack.common import log as logging @@ -71,12 +72,16 @@ class API(wsgi.Router): stack_mapper.connect("stack_lookup", r"/stacks/{stack_name:arn\x3A.*}", action="lookup") - subpaths = ['resources', 'events', 'template'] + subpaths = ['resources', 'events', 'template', 'actions'] path = "{path:%s}" % '|'.join(subpaths) stack_mapper.connect("stack_lookup_subpath", "/stacks/{stack_name}/" + path, action="lookup", conditions={'method': 'GET'}) + stack_mapper.connect("stack_lookup_subpath_post", + "/stacks/{stack_name}/" + path, + action="lookup", + conditions={'method': 'POST'}) stack_mapper.connect("stack_show", "/stacks/{stack_name}/{stack_id}", action="show", @@ -140,4 +145,14 @@ class API(wsgi.Router): action="show", conditions={'method': 'GET'}) + # Actions + actions_resource = actions.create_resource(conf) + with mapper.submapper(controller=actions_resource, + path_prefix=stack_path) as ac_mapper: + + ac_mapper.connect("action_stack", + "/actions", + action="action", + conditions={'method': 'POST'}) + super(API, self).__init__(mapper) diff --git a/heat/api/openstack/v1/actions.py b/heat/api/openstack/v1/actions.py new file mode 100644 index 00000000..acb74607 --- /dev/null +++ b/heat/api/openstack/v1/actions.py @@ -0,0 +1,69 @@ +# 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. + +from webob import exc + +from heat.api.openstack.v1 import util +from heat.common import wsgi +from heat.rpc import client as rpc_client +import heat.openstack.common.rpc.common as rpc_common + + +class ActionController(object): + """ + WSGI controller for Actions in Heat v1 API + Implements the API for stack actions + """ + + ACTIONS = (SUSPEND,) = ('suspend',) + + def __init__(self, options): + self.options = options + self.engine = rpc_client.EngineClient() + + @util.identified_stack + def action(self, req, identity, body={}): + """ + Performs a specified action on a stack, the body is expecting to + contain exactly one item whose key specifies the action + """ + + if len(body) < 1: + raise exc.HTTPBadRequest(_("No action specified")) + + if len(body) > 1: + raise exc.HTTPBadRequest(_("Multiple actions specified")) + + ac = body.keys()[0] + if ac not in self.ACTIONS: + raise exc.HTTPBadRequest(_("Invalid action %s specified") % ac) + + if ac == self.SUSPEND: + try: + res = self.engine.stack_suspend(req.context, identity) + except rpc_common.RemoteError as ex: + return util.remote_error(ex) + else: + raise exc.HTTPInternalServerError(_("Unexpected action %s") % ac) + + +def create_resource(options): + """ + Actions action factory method. + """ + # TODO(zaneb) handle XML based on Content-type/Accepts + deserializer = wsgi.JSONRequestDeserializer() + serializer = wsgi.JSONResponseSerializer() + return wsgi.Resource(ActionController(options), deserializer, serializer) diff --git a/heat/tests/test_api_openstack_v1.py b/heat/tests/test_api_openstack_v1.py index e8c53c22..7783a712 100644 --- a/heat/tests/test_api_openstack_v1.py +++ b/heat/tests/test_api_openstack_v1.py @@ -30,6 +30,7 @@ import heat.api.openstack.v1 as api_v1 import heat.api.openstack.v1.stacks as stacks import heat.api.openstack.v1.resources as resources import heat.api.openstack.v1.events as events +import heat.api.openstack.v1.actions as actions class InstantiationDataTest(HeatTestCase): @@ -1902,6 +1903,32 @@ class RoutesTest(HeatTestCase): 'path': 'template' }) + def test_stack_post_actions(self): + self.assertRoute( + self.m, + '/aaaa/stacks/teststack/bbbb/actions', + 'POST', + 'action', + 'ActionController', + { + 'tenant_id': 'aaaa', + 'stack_name': 'teststack', + 'stack_id': 'bbbb', + }) + + def test_stack_post_actions_lookup_redirect(self): + self.assertRoute( + self.m, + '/aaaa/stacks/teststack/actions', + 'POST', + 'lookup', + 'StackController', + { + 'tenant_id': 'aaaa', + 'stack_name': 'teststack', + 'path': 'actions' + }) + def test_stack_update_delete(self): self.assertRoute( self.m, @@ -2000,3 +2027,140 @@ class RoutesTest(HeatTestCase): 'resource_name': 'cccc', 'event_id': 'dddd' }) + + +class ActionControllerTest(ControllerTest, HeatTestCase): + ''' + Tests the API class which acts as the WSGI controller, + the endpoint processing API requests after they are routed + ''' + + def setUp(self): + super(ActionControllerTest, self).setUp() + # Create WSGI controller instance + + class DummyConfig(): + bind_port = 8004 + + cfgopts = DummyConfig() + self.controller = actions.ActionController(options=cfgopts) + + def test_action_suspend(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {'suspend': None} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'namespace': None, + 'method': 'stack_suspend', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndReturn(None) + self.m.ReplayAll() + + result = self.controller.action(req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + self.assertEqual(result, None) + self.m.VerifyAll() + + def test_action_badaction(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {'notallowed': None} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.action, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_action_badaction_empty(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.action, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_action_badaction_multiple(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {'one': None, 'two': None} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.action, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_action_rmt_aterr(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {'suspend': None} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.StubOutWithMock(rpc, 'call') + rpc.call(req.context, self.topic, + {'namespace': None, + 'method': 'stack_suspend', + 'args': {'stack_identity': stack_identity}, + 'version': self.api_version}, + None).AndRaise(rpc_common.RemoteError("AttributeError")) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.action, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + + self.m.VerifyAll() + + def test_action_badaction_ise(self): + res_name = 'WikiDatabase' + stack_identity = identifier.HeatIdentifier(self.tenant, + 'wordpress', '1') + body = {'oops': None} + req = self._post(stack_identity._tenant_path() + '/actions', + data=json.dumps(body)) + + self.m.ReplayAll() + + self.controller.ACTIONS = (SUSPEND, NEW) = ('suspend', 'oops') + + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.action, + req, tenant_id=self.tenant, + stack_name=stack_identity.stack_name, + stack_id=stack_identity.stack_id, + body=body) + self.m.VerifyAll() -- 2.45.2