diff --git a/etc/heat/policy.json b/etc/heat/policy.json index 982480693d..62143febff 100644 --- a/etc/heat/policy.json +++ b/etc/heat/policy.json @@ -50,6 +50,7 @@ "stacks:show": "rule:deny_stack_user", "stacks:template": "rule:deny_stack_user", "stacks:update": "rule:deny_stack_user", + "stacks:update_patch": "rule:deny_stack_user", "stacks:validate_template": "rule:deny_stack_user", "stacks:snapshot": "rule:deny_stack_user", "stacks:show_snapshot": "rule:deny_stack_user", diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py index ad2c875ef1..862a474fa0 100644 --- a/heat/api/openstack/v1/__init__.py +++ b/heat/api/openstack/v1/__init__.py @@ -106,6 +106,10 @@ class API(wsgi.Router): "/stacks/{stack_name}/{stack_id}", action="update", conditions={'method': 'PUT'}) + stack_mapper.connect("stack_update_patch", + "/stacks/{stack_name}/{stack_id}", + action="update_patch", + conditions={'method': 'PATCH'}) stack_mapper.connect("stack_delete", "/stacks/{stack_name}/{stack_id}", action="delete", diff --git a/heat/api/openstack/v1/stacks.py b/heat/api/openstack/v1/stacks.py index 792df22dda..c38201f407 100644 --- a/heat/api/openstack/v1/stacks.py +++ b/heat/api/openstack/v1/stacks.py @@ -56,9 +56,15 @@ class InstantiationData(object): 'files', ) - def __init__(self, data): - """Initialise from the request object.""" + def __init__(self, data, patch=False): + """ + Initialise from the request object. + If called from the PATCH api, insert a flag for the engine code + to distinguish. + """ self.data = data + if patch: + self.data[engine_api.PARAM_EXISTING] = True @staticmethod def format_parse(data, data_type): @@ -343,6 +349,22 @@ class StackController(object): raise exc.HTTPAccepted() + @util.identified_stack + def update_patch(self, req, identity, body): + """ + Update an existing stack with a new template by patching the parameters + Add the flag patch to the args so the engine code can distinguish + """ + data = InstantiationData(body, patch=True) + self.rpc_client.update_stack(req.context, + identity, + data.template(), + data.environment(), + data.files(), + data.args()) + + raise exc.HTTPAccepted() + @util.identified_stack def delete(self, req, identity): """ diff --git a/heat/engine/environment.py b/heat/engine/environment.py index be3fdc6576..9ef0bbd940 100644 --- a/heat/engine/environment.py +++ b/heat/engine/environment.py @@ -11,6 +11,7 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import glob import itertools import os.path @@ -366,6 +367,15 @@ class Environment(object): self.registry.load(env_snippet.get(env_fmt.RESOURCE_REGISTRY, {})) self.params.update(env_snippet.get(env_fmt.PARAMETERS, {})) + def patch_previous_parameters(self, previous_env): + """This instance of Environment is the new environment where + we are reusing as default the previous parameter values. + """ + previous_parameters = copy.deepcopy(previous_env.params) + # patch the new set of parameters + previous_parameters.update(self.params) + self.params = previous_parameters + def user_env_as_dict(self): """Get the environment as a dict, ready for storing in the db.""" return {env_fmt.RESOURCE_REGISTRY: self.registry.as_dict(), diff --git a/heat/engine/service.py b/heat/engine/service.py index 7cd59ec375..cee9fb0965 100644 --- a/heat/engine/service.py +++ b/heat/engine/service.py @@ -657,6 +657,8 @@ class EngineService(service.Service): common_params.setdefault(rpc_api.PARAM_DISABLE_ROLLBACK, current_stack.disable_rollback) env = environment.Environment(params) + if args.get(rpc_api.PARAM_EXISTING, None): + env.patch_previous_parameters(current_stack.env) updated_stack = parser.Stack(cnxt, stack_name, tmpl, env, **common_params) updated_stack.parameters.set_stack_id(current_stack.identifier()) diff --git a/heat/rpc/api.py b/heat/rpc/api.py index 440e262bae..fe5a6ee550 100644 --- a/heat/rpc/api.py +++ b/heat/rpc/api.py @@ -15,10 +15,10 @@ ENGINE_TOPIC = 'engine' PARAM_KEYS = ( PARAM_TIMEOUT, PARAM_DISABLE_ROLLBACK, PARAM_ADOPT_STACK_DATA, - PARAM_SHOW_DELETED, PARAM_SHOW_NESTED + PARAM_SHOW_DELETED, PARAM_SHOW_NESTED, PARAM_EXISTING ) = ( 'timeout_mins', 'disable_rollback', 'adopt_stack_data', - 'show_deleted', 'show_nested' + 'show_deleted', 'show_nested', 'existing' ) STACK_KEYS = ( diff --git a/heat/tests/test_api_openstack_v1.py b/heat/tests/test_api_openstack_v1.py index 9974509713..6b935ee18e 100644 --- a/heat/tests/test_api_openstack_v1.py +++ b/heat/tests/test_api_openstack_v1.py @@ -284,6 +284,9 @@ class ControllerTest(object): def _put(self, path, data, content_type='application/json'): return self._data_request(path, data, content_type, method='PUT') + def _patch(self, path, data, content_type='application/json'): + return self._data_request(path, data, content_type, method='PATCH') + def _url(self, id): host = 'server.test:8004' path = '/v1/%(tenant)s/stacks/%(stack_name)s/%(stack_id)s%(path)s' % id @@ -1376,6 +1379,152 @@ class StackControllerTest(ControllerTest, HeatTestCase): self.assertEqual(403, resp.status_int) self.assertIn('403 Forbidden', six.text_type(resp)) + def test_update_with_existing_parameters(self, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update_patch', True) + identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '6') + template = {u'Foo': u'bar'} + body = {'template': template, + 'parameters': {}, + 'files': {}, + 'timeout_mins': 30} + + req = self._patch('/stacks/%(stack_name)s/%(stack_id)s' % identity, + json.dumps(body)) + + self.m.StubOutWithMock(rpc_client.EngineClient, 'call') + rpc_client.EngineClient.call( + req.context, + ('update_stack', + {'stack_identity': dict(identity), + 'template': template, + 'params': {'parameters': {}, + 'resource_registry': {}}, + 'files': {}, + 'args': {rpc_api.PARAM_EXISTING: True, + 'timeout_mins': 30}}) + ).AndReturn(dict(identity)) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPAccepted, + self.controller.update_patch, + req, tenant_id=identity.tenant, + stack_name=identity.stack_name, + stack_id=identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_update_with_patched_existing_parameters(self, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update_patch', True) + identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '6') + template = {u'Foo': u'bar'} + parameters = {u'InstanceType': u'm1.xlarge'} + body = {'template': template, + 'parameters': parameters, + 'files': {}, + 'timeout_mins': 30} + + req = self._patch('/stacks/%(stack_name)s/%(stack_id)s' % identity, + json.dumps(body)) + + self.m.StubOutWithMock(rpc_client.EngineClient, 'call') + rpc_client.EngineClient.call( + req.context, + ('update_stack', + {'stack_identity': dict(identity), + 'template': template, + 'params': {'parameters': parameters, + 'resource_registry': {}}, + 'files': {}, + 'args': {rpc_api.PARAM_EXISTING: True, + 'timeout_mins': 30}}) + ).AndReturn(dict(identity)) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPAccepted, + self.controller.update_patch, + req, tenant_id=identity.tenant, + stack_name=identity.stack_name, + stack_id=identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_update_with_existing_and_default_parameters( + self, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update_patch', True) + identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '6') + template = {u'Foo': u'bar'} + clear_params = [u'DBUsername', u'DBPassword', u'LinuxDistribution'] + body = {'template': template, + 'parameters': {}, + 'clear_parameters': clear_params, + 'files': {}, + 'timeout_mins': 30} + + req = self._patch('/stacks/%(stack_name)s/%(stack_id)s' % identity, + json.dumps(body)) + + self.m.StubOutWithMock(rpc_client.EngineClient, 'call') + rpc_client.EngineClient.call( + req.context, + ('update_stack', + {'stack_identity': dict(identity), + 'template': template, + 'params': {'parameters': {}, + 'resource_registry': {}}, + 'files': {}, + 'args': {rpc_api.PARAM_EXISTING: True, + 'clear_parameters': clear_params, + 'timeout_mins': 30}}) + ).AndReturn(dict(identity)) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPAccepted, + self.controller.update_patch, + req, tenant_id=identity.tenant, + stack_name=identity.stack_name, + stack_id=identity.stack_id, + body=body) + self.m.VerifyAll() + + def test_update_with_patched_and_default_parameters( + self, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update_patch', True) + identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '6') + template = {u'Foo': u'bar'} + parameters = {u'InstanceType': u'm1.xlarge'} + clear_params = [u'DBUsername', u'DBPassword', u'LinuxDistribution'] + body = {'template': template, + 'parameters': parameters, + 'clear_parameters': clear_params, + 'files': {}, + 'timeout_mins': 30} + + req = self._patch('/stacks/%(stack_name)s/%(stack_id)s' % identity, + json.dumps(body)) + + self.m.StubOutWithMock(rpc_client.EngineClient, 'call') + rpc_client.EngineClient.call( + req.context, + ('update_stack', + {'stack_identity': dict(identity), + 'template': template, + 'params': {'parameters': parameters, + 'resource_registry': {}}, + 'files': {}, + 'args': {rpc_api.PARAM_EXISTING: True, + 'clear_parameters': clear_params, + 'timeout_mins': 30}}) + ).AndReturn(dict(identity)) + self.m.ReplayAll() + + self.assertRaises(webob.exc.HTTPAccepted, + self.controller.update_patch, + req, tenant_id=identity.tenant, + stack_name=identity.stack_name, + stack_id=identity.stack_id, + body=body) + self.m.VerifyAll() + def test_delete(self, mock_enforce): self._mock_enforce_setup(mock_enforce, 'delete', True) identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '6') diff --git a/heat/tests/test_engine_service.py b/heat/tests/test_engine_service.py index 802faf3c0d..a862b3e662 100644 --- a/heat/tests/test_engine_service.py +++ b/heat/tests/test_engine_service.py @@ -81,6 +81,30 @@ wp_template = ''' } ''' +wp_template_no_default = ''' +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Description" : "WordPress", + "Parameters" : { + "KeyName" : { + "Description" : "KeyName", + "Type" : "String" + } + }, + "Resources" : { + "WebServer": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId" : "F17-x86_64-gold", + "InstanceType" : "m1.large", + "KeyName" : "test", + "UserData" : "wordpress" + } + } + } +} +''' + nested_alarm_template = ''' HeatTemplateFormatVersion: '2012-12-12' Resources: @@ -184,6 +208,14 @@ def get_wordpress_stack(stack_name, ctx): return stack +def get_wordpress_stack_no_params(stack_name, ctx): + t = template_format.parse(wp_template) + template = parser.Template(t) + stack = parser.Stack(ctx, stack_name, template, + environment.Environment({})) + return stack + + def get_stack(stack_name, ctx, template): t = template_format.parse(template) template = templatem.Template(t) @@ -896,6 +928,56 @@ class StackServiceCreateUpdateDeleteTest(HeatTestCase): self.assertEqual(self.man.thread_group_mgr.events[sid], [evt_mock]) self.m.VerifyAll() + def test_stack_update_existing_parameters(self): + '''Use a template with default parameter and no input parameter + then update with a template without default and no input + parameter, using the existing parameter. + ''' + stack_name = 'service_update_test_stack_existing_parameters' + no_params = {} + with_params = {'KeyName': 'foo'} + + old_stack = get_wordpress_stack_no_params(stack_name, self.ctx) + sid = old_stack.store() + s = db_api.stack_get(self.ctx, sid) + + t = template_format.parse(wp_template_no_default) + template = parser.Template(t) + env = environment.Environment({'parameters': with_params, + 'resource_registry': {'rsc': 'test'}}) + stack = parser.Stack(self.ctx, stack_name, template, env) + + self._stub_update_mocks(s, old_stack) + + templatem.Template(wp_template_no_default, + files=None).AndReturn(stack.t) + environment.Environment(no_params).AndReturn(old_stack.env) + parser.Stack(self.ctx, stack.name, + stack.t, old_stack.env, + timeout_mins=60, disable_rollback=True).AndReturn(stack) + + self.m.StubOutWithMock(stack, 'validate') + stack.validate().AndReturn(None) + + evt_mock = self.m.CreateMockAnything() + self.m.StubOutWithMock(grevent, 'Event') + grevent.Event().AndReturn(evt_mock) + self.m.StubOutWithMock(threadgroup, 'ThreadGroup') + threadgroup.ThreadGroup().AndReturn(DummyThreadGroup()) + + self.m.ReplayAll() + + api_args = {engine_api.PARAM_TIMEOUT: 60, + engine_api.PARAM_EXISTING: True} + result = self.man.update_stack(self.ctx, old_stack.identifier(), + wp_template_no_default, no_params, + None, api_args) + self.assertEqual(old_stack.identifier(), result) + self.assertIsInstance(result, dict) + self.assertTrue(result['stack_id']) + self.assertEqual(self.man.thread_group_mgr.events[sid], [evt_mock]) + self.m.VerifyAll() + def test_stack_update_reuses_api_params(self): stack_name = 'service_update_test_stack' params = {'foo': 'bar'} diff --git a/heat/tests/test_environment.py b/heat/tests/test_environment.py index eb131f483a..62febfff7c 100644 --- a/heat/tests/test_environment.py +++ b/heat/tests/test_environment.py @@ -47,6 +47,32 @@ class EnvironmentTest(common.HeatTestCase): env = environment.Environment(new_env) self.assertEqual(new_env, env.user_env_as_dict()) + def test_existing_parameters(self): + # This tests reusing the existing parameters as is + prev_params = {'foo': 'bar', 'tester': 'Yes'} + params = {} + expected = {'parameters': prev_params, + 'resource_registry': {'resources': {}}} + prev_env = environment.Environment( + {'parameters': prev_params, + 'resource_registry': {'resources': {}}}) + env = environment.Environment(params) + env.patch_previous_parameters(prev_env) + self.assertEqual(expected, env.user_env_as_dict()) + + def test_patch_existing_parameters(self): + # This tests patching cli parameters over the existing parameters + prev_params = {'foo': 'bar', 'tester': 'Yes'} + params = {'tester': 'patched'} + expected = {'parameters': {'foo': 'bar', 'tester': 'patched'}, + 'resource_registry': {'resources': {}}} + prev_env = environment.Environment( + {'parameters': prev_params, + 'resource_registry': {'resources': {}}}) + env = environment.Environment(params) + env.patch_previous_parameters(prev_env) + self.assertEqual(expected, env.user_env_as_dict()) + def test_global_registry(self): self.g_env.register_class('CloudX::Nova::Server', generic_resource.GenericResource)