diff --git a/etc/heat/policy.json b/etc/heat/policy.json index 06314cd8db..8043d8b031 100644 --- a/etc/heat/policy.json +++ b/etc/heat/policy.json @@ -53,6 +53,7 @@ "stacks:show": "rule:deny_stack_user", "stacks:template": "rule:deny_stack_user", "stacks:update": "rule:deny_stack_user", + "stacks:preview_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", diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py index 3323b26232..6dc6829d48 100644 --- a/heat/api/openstack/v1/__init__.py +++ b/heat/api/openstack/v1/__init__.py @@ -208,6 +208,12 @@ class API(wsgi.Router): 'action': 'update_patch', 'method': 'PATCH' }, + { + 'name': 'preview_stack_update', + 'url': '/stacks/{stack_name}/{stack_id}/preview', + 'action': 'preview_update', + 'method': 'PUT' + }, { 'name': 'stack_delete', 'url': '/stacks/{stack_name}/{stack_id}', diff --git a/heat/api/openstack/v1/stacks.py b/heat/api/openstack/v1/stacks.py index d7a72238ff..0278671dde 100644 --- a/heat/api/openstack/v1/stacks.py +++ b/heat/api/openstack/v1/stacks.py @@ -458,6 +458,24 @@ class StackController(object): raise exc.HTTPAccepted() + @util.identified_stack + def preview_update(self, req, identity, body): + """ + Preview an update to an existing stack with a new template/parameters + """ + data = InstantiationData(body) + + args = self.prepare_args(data) + changes = self.rpc_client.preview_update_stack( + req.context, + identity, + data.template(), + data.environment(), + data.files(), + args) + + return {'resource_changes': changes} + @util.identified_stack def delete(self, req, identity): """ diff --git a/heat/engine/resource.py b/heat/engine/resource.py index dae5192f41..e98e595ccd 100644 --- a/heat/engine/resource.py +++ b/heat/engine/resource.py @@ -865,8 +865,11 @@ class Resource(object): resource_data.get('metadata')) def _needs_update(self, after, before, after_props, before_props, - prev_resource): - if self.status == self.FAILED or \ + prev_resource, check_init_complete=True): + if self.status == self.FAILED: + raise UpdateReplace(self) + + if check_init_complete and \ (self.action == self.INIT and self.status == self.COMPLETE): raise UpdateReplace(self) diff --git a/heat/engine/resources/openstack/heat/none_resource.py b/heat/engine/resources/openstack/heat/none_resource.py index bd3a2ca5a9..bbe86e468d 100644 --- a/heat/engine/resources/openstack/heat/none_resource.py +++ b/heat/engine/resources/openstack/heat/none_resource.py @@ -34,7 +34,7 @@ class NoneResource(resource.Resource): attributes_schema = {} def _needs_update(self, after, before, after_props, before_props, - prev_resource): + prev_resource, check_init_complete=True): return False def reparse(self): diff --git a/heat/engine/resources/openstack/heat/remote_stack.py b/heat/engine/resources/openstack/heat/remote_stack.py index 67e4f37b5d..375404e0be 100644 --- a/heat/engine/resources/openstack/heat/remote_stack.py +++ b/heat/engine/resources/openstack/heat/remote_stack.py @@ -212,7 +212,7 @@ class RemoteStack(resource.Resource): self.heat().actions.check(stack_id=self.resource_id) def _needs_update(self, after, before, after_props, before_props, - prev_resource): + prev_resource, check_init_complete=True): # Always issue an update to the remote stack and let the individual # resources in it decide if they need updating. return True diff --git a/heat/engine/resources/openstack/neutron/port.py b/heat/engine/resources/openstack/neutron/port.py index daae4ec8af..6664d44036 100644 --- a/heat/engine/resources/openstack/neutron/port.py +++ b/heat/engine/resources/openstack/neutron/port.py @@ -413,13 +413,14 @@ class Port(neutron.NeutronResource): return super(Port, self)._resolve_attribute(name) def _needs_update(self, after, before, after_props, before_props, - prev_resource): + prev_resource, check_init_complete=True): if after_props.get(self.REPLACEMENT_POLICY) == 'REPLACE_ALWAYS': raise resource.UpdateReplace(self.name) return super(Port, self)._needs_update( - after, before, after_props, before_props, prev_resource) + after, before, after_props, before_props, prev_resource, + check_init_complete) def handle_update(self, json_snippet, tmpl_diff, prop_diff): props = self.prepare_update_properties(json_snippet) diff --git a/heat/engine/resources/stack_resource.py b/heat/engine/resources/stack_resource.py index cb2590b3b9..434ee99b94 100644 --- a/heat/engine/resources/stack_resource.py +++ b/heat/engine/resources/stack_resource.py @@ -86,16 +86,18 @@ class StackResource(resource.Resource): self._resolve_all_attributes) def _needs_update(self, after, before, after_props, before_props, - prev_resource): + prev_resource, check_init_complete=True): # Issue an update to the nested stack if the stack resource # is able to update. If return true, let the individual # resources in it decide if they need updating. # FIXME (ricolin): seems currently can not call super here - if self.nested() is None and ( - self.status == self.FAILED - or (self.action == self.INIT - and self.status == self.COMPLETE)): + if self.nested() is None and self.status == self.FAILED: + raise resource.UpdateReplace(self) + + if (check_init_complete and + self.nested() is None and + self.action == self.INIT and self.status == self.COMPLETE): raise resource.UpdateReplace(self) return True diff --git a/heat/engine/service.py b/heat/engine/service.py index f8c0459bc7..7fd2b3c2e6 100644 --- a/heat/engine/service.py +++ b/heat/engine/service.py @@ -57,6 +57,7 @@ from heat.engine import stack as parser from heat.engine import stack_lock from heat.engine import support from heat.engine import template as templatem +from heat.engine import update from heat.engine import watchrule from heat.engine import worker from heat.objects import event as event_object @@ -272,7 +273,7 @@ class EngineService(service.Service): by the RPC caller. """ - RPC_API_VERSION = '1.14' + RPC_API_VERSION = '1.15' def __init__(self, host, topic): super(EngineService, self).__init__() @@ -717,6 +718,46 @@ class EngineService(service.Service): return dict(stack.identifier()) + def _prepare_stack_updates(self, cnxt, current_stack, tmpl, params, + files, args): + """ + Given a stack and update context, return the current and updated stack. + + Changes *will not* be persisted, this is a helper method for + update_stack and preview_update_stack. + + :param cnxt: RPC context. + :param stack: A stack to be updated. + :param tmpl: Template object of stack you want to update to. + :param params: Stack Input Params + :param files: Files referenced from the template + :param args: Request parameters/args passed from API + """ + max_resources = cfg.CONF.max_resources_per_stack + if max_resources != -1 and len(tmpl[tmpl.RESOURCES]) > max_resources: + raise exception.RequestLimitExceeded( + message=exception.StackResourceLimitExceeded.msg_fmt) + + stack_name = current_stack.name + current_kwargs = current_stack.get_kwargs_for_cloning() + + common_params = api.extract_args(args) + common_params.setdefault(rpc_api.PARAM_TIMEOUT, + current_stack.timeout_mins) + common_params.setdefault(rpc_api.PARAM_DISABLE_ROLLBACK, + current_stack.disable_rollback) + + current_kwargs.update(common_params) + updated_stack = parser.Stack(cnxt, stack_name, tmpl, + **current_kwargs) + self.resource_enforcer.enforce_stack(updated_stack) + updated_stack.parameters.set_stack_id(current_stack.identifier()) + + self._validate_deferred_auth_context(cnxt, updated_stack) + updated_stack.validate() + + return current_stack, updated_stack + @context.request_context def update_stack(self, cnxt, stack_identity, template, params, files, args): @@ -767,29 +808,11 @@ class EngineService(service.Service): new_env = environment.Environment(params) new_files = files tmpl = templatem.Template(template, files=new_files, env=new_env) - max_resources = cfg.CONF.max_resources_per_stack - if max_resources != -1 and len(tmpl[tmpl.RESOURCES]) > max_resources: - raise exception.RequestLimitExceeded( - message=exception.StackResourceLimitExceeded.msg_fmt) - stack_name = current_stack.name - current_kwargs = current_stack.get_kwargs_for_cloning() - common_params = api.extract_args(args) - common_params.setdefault(rpc_api.PARAM_TIMEOUT, - current_stack.timeout_mins) - common_params.setdefault(rpc_api.PARAM_DISABLE_ROLLBACK, - current_stack.disable_rollback) + current_stack, updated_stack = self._prepare_stack_updates( + cnxt, current_stack, tmpl, params, files, args) - current_kwargs.update(common_params) - updated_stack = parser.Stack(cnxt, stack_name, tmpl, - **current_kwargs) - self.resource_enforcer.enforce_stack(updated_stack) - updated_stack.parameters.set_stack_id(current_stack.identifier()) - - self._validate_deferred_auth_context(cnxt, updated_stack) - updated_stack.validate() - - if current_kwargs['convergence']: + if current_stack.get_kwargs_for_cloning()['convergence']: current_stack.converge_stack(template=tmpl, new_stack=updated_stack) else: @@ -804,6 +827,57 @@ class EngineService(service.Service): self.thread_group_mgr.add_event(current_stack.id, event) return dict(current_stack.identifier()) + @context.request_context + def preview_update_stack(self, cnxt, stack_identity, template, params, + files, args): + """ + The preview_update_stack method shows the resources that would be + changed with an update to an existing stack based on the provided + template and parameters. See update_stack for description of + parameters. + + This method *cannot* guarantee that an update will have the actions + specified because resource plugins can influence changes/replacements + at runtime. + + Note that at this stage the template has already been fetched from the + heat-api process if using a template-url. + """ + # Get the database representation of the existing stack + db_stack = self._get_stack(cnxt, stack_identity) + LOG.info(_LI('Previewing update of stack %s'), db_stack.name) + + current_stack = parser.Stack.load(cnxt, stack=db_stack) + + # Now parse the template and any parameters for the updated + # stack definition. + env = environment.Environment(params) + if args.get(rpc_api.PARAM_EXISTING, None): + env.patch_previous_parameters( + current_stack.env, + args.get(rpc_api.PARAM_CLEAR_PARAMETERS, [])) + tmpl = templatem.Template(template, files=files, env=env) + + current_stack, updated_stack = self._prepare_stack_updates( + cnxt, current_stack, tmpl, params, files, args) + + update_task = update.StackUpdate(current_stack, updated_stack, None) + + actions = update_task.preview() + + fmt_updated_res = lambda k: api.format_stack_resource( + updated_stack.resources.get(k)) + fmt_current_res = lambda k: api.format_stack_resource( + current_stack.resources.get(k)) + + return { + 'unchanged': map(fmt_updated_res, actions['unchanged']), + 'updated': map(fmt_current_res, actions['updated']), + 'replaced': map(fmt_updated_res, actions['replaced']), + 'added': map(fmt_updated_res, actions['added']), + 'deleted': map(fmt_current_res, actions['deleted']), + } + @context.request_context def stack_cancel_update(self, cnxt, stack_identity, cancel_with_rollback=True): diff --git a/heat/engine/update.py b/heat/engine/update.py index db2c78d56e..672560cefd 100644 --- a/heat/engine/update.py +++ b/heat/engine/update.py @@ -219,3 +219,43 @@ class StackUpdate(object): yield (res, self.new_stack[name]) return dependencies.Dependencies(edges()) + + def preview(self): + upd_keys = set(self.new_stack.resources.keys()) + cur_keys = set(self.existing_stack.resources.keys()) + + common_keys = cur_keys.intersection(upd_keys) + deleted_keys = cur_keys.difference(upd_keys) + added_keys = upd_keys.difference(cur_keys) + + updated_keys = [] + replaced_keys = [] + + for key in common_keys: + current_res = self.existing_stack.resources[key] + updated_res = self.new_stack.resources[key] + + current_props = current_res.frozen_definition().properties( + current_res.properties_schema, current_res.context) + updated_props = updated_res.frozen_definition().properties( + updated_res.properties_schema, updated_res.context) + + try: + if current_res._needs_update(updated_res.frozen_definition(), + current_res.frozen_definition(), + updated_props, current_props, + None, check_init_complete=False): + current_res.update_template_diff_properties(updated_props, + current_props) + updated_keys.append(key) + except resource.UpdateReplace: + replaced_keys.append(key) + + return { + 'unchanged': list(set(common_keys).difference( + set(updated_keys + replaced_keys))), + 'updated': updated_keys, + 'replaced': replaced_keys, + 'added': added_keys, + 'deleted': deleted_keys, + } diff --git a/heat/rpc/client.py b/heat/rpc/client.py index 4cb43a7ef6..b09eaa971d 100644 --- a/heat/rpc/client.py +++ b/heat/rpc/client.py @@ -35,6 +35,7 @@ class EngineClient(object): 1.12 - Add with_detail option for stack resources list 1.13 - Add support for template functions list 1.14 - Add cancel_with_rollback option to stack_cancel_update + 1.15 - Add preview_update_stack() call ''' BASE_RPC_API_VERSION = '1.0' @@ -264,6 +265,32 @@ class EngineClient(object): files=files, args=args)) + def preview_update_stack(self, ctxt, stack_identity, template, params, + files, args): + """ + The preview_update_stack method returns the resources that would be + changed in an update of an existing stack based on the provided + template and parameters. + + Requires RPC version 1.15 or above. + + :param ctxt: RPC context. + :param stack_identity: Name of the stack you wish to update. + :param template: New template for the stack. + :param params: Stack Input Params/Environment + :param files: files referenced from the environment. + :param args: Request parameters/args passed from API + """ + return self.call(ctxt, + self.make_msg('preview_update_stack', + stack_identity=stack_identity, + template=template, + params=params, + files=files, + args=args, + ), + version='1.15') + def validate_template(self, ctxt, template, params=None): """ The validate_template method uses the stack parser to check diff --git a/heat/tests/api/openstack_v1/test_stacks.py b/heat/tests/api/openstack_v1/test_stacks.py index 2e9b450525..1a0297c018 100644 --- a/heat/tests/api/openstack_v1/test_stacks.py +++ b/heat/tests/api/openstack_v1/test_stacks.py @@ -1101,6 +1101,47 @@ class StackControllerTest(tools.ControllerTest, common.HeatTestCase): self.assertEqual({'stack': 'formatted_stack'}, result) + def test_preview_update_stack(self, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'preview_update', 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._put('/stacks/%(stack_name)s/%(stack_id)s/preview' % + identity, json.dumps(body)) + resource_changes = {'updated': [], + 'deleted': [], + 'unchanged': [], + 'added': [], + 'replaced': []} + + self.m.StubOutWithMock(rpc_client.EngineClient, 'call') + rpc_client.EngineClient.call( + req.context, + ('preview_update_stack', + {'stack_identity': dict(identity), + 'template': template, + 'params': {'parameters': parameters, + 'encrypted_param_names': [], + 'parameter_defaults': {}, + 'resource_registry': {}}, + 'files': {}, + 'args': {'timeout_mins': 30}}), + version='1.15' + ).AndReturn(resource_changes) + self.m.ReplayAll() + + result = self.controller.preview_update(req, tenant_id=identity.tenant, + stack_name=identity.stack_name, + stack_id=identity.stack_id, + body=body) + self.assertEqual({'resource_changes': resource_changes}, result) + self.m.VerifyAll() + def test_lookup(self, mock_enforce): self._mock_enforce_setup(mock_enforce, 'lookup', True) identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '1') diff --git a/heat/tests/engine/service/test_service_engine.py b/heat/tests/engine/service/test_service_engine.py index e6be403498..c1e9edaac8 100644 --- a/heat/tests/engine/service/test_service_engine.py +++ b/heat/tests/engine/service/test_service_engine.py @@ -39,7 +39,7 @@ class ServiceEngineTest(common.HeatTestCase): def test_make_sure_rpc_version(self): self.assertEqual( - '1.14', + '1.15', service.EngineService.RPC_API_VERSION, ('RPC version is changed, please update this test to new version ' 'and make sure additional test cases are added for RPC APIs ' diff --git a/heat/tests/test_engine_service.py b/heat/tests/test_engine_service.py index ce377d4cec..45804cb062 100644 --- a/heat/tests/test_engine_service.py +++ b/heat/tests/test_engine_service.py @@ -894,6 +894,209 @@ class StackServiceAdoptUpdateTest(common.HeatTestCase): self.m.VerifyAll() + def _test_stack_update_preview(self, orig_template, new_template): + stack_name = 'service_update_test_stack' + params = {'foo': 'bar'} + old_stack = tools.get_stack(stack_name, self.ctx, + template=orig_template) + sid = old_stack.store() + old_stack.set_stack_user_project_id('1234') + s = stack_object.Stack.get_by_id(self.ctx, sid) + + stack = tools.get_stack(stack_name, self.ctx, template=new_template) + + self._stub_update_mocks(s, old_stack) + + templatem.Template(new_template, files=None, + env=stack.env).AndReturn(stack.t) + environment.Environment(params).AndReturn(stack.env) + parser.Stack(self.ctx, stack.name, + stack.t, + convergence=False, + current_traversal=None, + prev_raw_template_id=None, + current_deps=None, + disable_rollback=True, + nested_depth=0, + owner_id=None, + parent_resource=None, + stack_user_project_id='1234', + strict_validate=True, + tenant_id='test_tenant_id', + timeout_mins=60, + user_creds_id=u'1', + username='test_username').AndReturn(stack) + + self.m.StubOutWithMock(stack, 'validate') + stack.validate().AndReturn(None) + self.m.ReplayAll() + + api_args = {'timeout_mins': 60} + result = self.man.preview_update_stack(self.ctx, + old_stack.identifier(), + new_template, params, None, + api_args) + self.m.VerifyAll() + + return result + + def test_stack_update_preview_added_unchanged(self): + orig_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress +''' + + new_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress + password: + type: OS::Heat::RandomString + properties: + length: 8 +''' + + result = self._test_stack_update_preview(orig_template, new_template) + + added = [x for x in result['added']][0] + self.assertEqual(added['resource_name'], 'password') + unchanged = [x for x in result['unchanged']][0] + self.assertEqual(unchanged['resource_name'], 'web_server') + + empty_sections = ('deleted', 'replaced', 'updated') + for section in empty_sections: + section_contents = [x for x in result[section]] + self.assertEqual(section_contents, []) + + self.m.VerifyAll() + + def test_stack_update_preview_replaced(self): + orig_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress +''' + + new_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test2 + user_data: wordpress +''' + + result = self._test_stack_update_preview(orig_template, new_template) + + replaced = [x for x in result['replaced']][0] + self.assertEqual(replaced['resource_name'], 'web_server') + empty_sections = ('added', 'deleted', 'unchanged', 'updated') + for section in empty_sections: + section_contents = [x for x in result[section]] + self.assertEqual(section_contents, []) + + self.m.VerifyAll() + + def test_stack_update_preview_updated(self): + orig_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress +''' + + new_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.small + key_name: test + user_data: wordpress +''' + + result = self._test_stack_update_preview(orig_template, new_template) + + updated = [x for x in result['updated']][0] + self.assertEqual(updated['resource_name'], 'web_server') + empty_sections = ('added', 'deleted', 'unchanged', 'replaced') + for section in empty_sections: + section_contents = [x for x in result[section]] + self.assertEqual(section_contents, []) + + self.m.VerifyAll() + + def test_stack_update_preview_deleted(self): + orig_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress + password: + type: OS::Heat::RandomString + properties: + length: 8 +''' + + new_template = ''' +heat_template_version: 2014-10-16 +resources: + web_server: + type: OS::Nova::Server + properties: + image: F17-x86_64-gold + flavor: m1.large + key_name: test + user_data: wordpress +''' + + result = self._test_stack_update_preview(orig_template, new_template) + + deleted = [x for x in result['deleted']][0] + self.assertEqual(deleted['resource_name'], 'password') + unchanged = [x for x in result['unchanged']][0] + self.assertEqual(unchanged['resource_name'], 'web_server') + empty_sections = ('added', 'updated', 'replaced') + for section in empty_sections: + section_contents = [x for x in result[section]] + self.assertEqual(section_contents, []) + + self.m.VerifyAll() + class StackConvergenceServiceCreateUpdateTest(common.HeatTestCase): diff --git a/heat/tests/test_rpc_client.py b/heat/tests/test_rpc_client.py index e04c3d178d..2bad6dba5a 100644 --- a/heat/tests/test_rpc_client.py +++ b/heat/tests/test_rpc_client.py @@ -175,6 +175,14 @@ class EngineRpcAPITestCase(common.HeatTestCase): files={}, args=mock.ANY) + def test_preview_update_stack(self): + self._test_engine_api('preview_update_stack', 'call', + stack_identity=self.identity, + template={u'Foo': u'bar'}, + params={u'InstanceType': u'm1.xlarge'}, + files={}, + args=mock.ANY) + def test_get_template(self): self._test_engine_api('get_template', 'call', stack_identity=self.identity)