Add a preview endpoint for stack updates

Allow users to see what resources will be changed during a stack-update.

Docs change here https://review.openstack.org/132870/

Client change here https://review.openstack.org/#/c/126957/

BP: update-dry-run

Co-Authored-By: Jason Dunsmore <jasondunsmore@gmail.com>
Change-Id: If58bdcccfef6f5d36c0367c5267f95014232015e
This commit is contained in:
Ryan Brown 2015-06-08 10:40:09 -04:00 committed by Jason Dunsmore
parent 2dbcd9064d
commit 6513d3944c
15 changed files with 458 additions and 34 deletions

View File

@ -53,6 +53,7 @@
"stacks:show": "rule:deny_stack_user", "stacks:show": "rule:deny_stack_user",
"stacks:template": "rule:deny_stack_user", "stacks:template": "rule:deny_stack_user",
"stacks:update": "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:update_patch": "rule:deny_stack_user",
"stacks:validate_template": "rule:deny_stack_user", "stacks:validate_template": "rule:deny_stack_user",
"stacks:snapshot": "rule:deny_stack_user", "stacks:snapshot": "rule:deny_stack_user",

View File

@ -208,6 +208,12 @@ class API(wsgi.Router):
'action': 'update_patch', 'action': 'update_patch',
'method': 'PATCH' 'method': 'PATCH'
}, },
{
'name': 'preview_stack_update',
'url': '/stacks/{stack_name}/{stack_id}/preview',
'action': 'preview_update',
'method': 'PUT'
},
{ {
'name': 'stack_delete', 'name': 'stack_delete',
'url': '/stacks/{stack_name}/{stack_id}', 'url': '/stacks/{stack_name}/{stack_id}',

View File

@ -458,6 +458,24 @@ class StackController(object):
raise exc.HTTPAccepted() 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 @util.identified_stack
def delete(self, req, identity): def delete(self, req, identity):
""" """

View File

@ -865,8 +865,11 @@ class Resource(object):
resource_data.get('metadata')) resource_data.get('metadata'))
def _needs_update(self, after, before, after_props, before_props, def _needs_update(self, after, before, after_props, before_props,
prev_resource): prev_resource, check_init_complete=True):
if self.status == self.FAILED or \ if self.status == self.FAILED:
raise UpdateReplace(self)
if check_init_complete and \
(self.action == self.INIT and self.status == self.COMPLETE): (self.action == self.INIT and self.status == self.COMPLETE):
raise UpdateReplace(self) raise UpdateReplace(self)

View File

@ -34,7 +34,7 @@ class NoneResource(resource.Resource):
attributes_schema = {} attributes_schema = {}
def _needs_update(self, after, before, after_props, before_props, def _needs_update(self, after, before, after_props, before_props,
prev_resource): prev_resource, check_init_complete=True):
return False return False
def reparse(self): def reparse(self):

View File

@ -212,7 +212,7 @@ class RemoteStack(resource.Resource):
self.heat().actions.check(stack_id=self.resource_id) self.heat().actions.check(stack_id=self.resource_id)
def _needs_update(self, after, before, after_props, before_props, 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 # Always issue an update to the remote stack and let the individual
# resources in it decide if they need updating. # resources in it decide if they need updating.
return True return True

View File

@ -413,13 +413,14 @@ class Port(neutron.NeutronResource):
return super(Port, self)._resolve_attribute(name) return super(Port, self)._resolve_attribute(name)
def _needs_update(self, after, before, after_props, before_props, 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': if after_props.get(self.REPLACEMENT_POLICY) == 'REPLACE_ALWAYS':
raise resource.UpdateReplace(self.name) raise resource.UpdateReplace(self.name)
return super(Port, self)._needs_update( 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): def handle_update(self, json_snippet, tmpl_diff, prop_diff):
props = self.prepare_update_properties(json_snippet) props = self.prepare_update_properties(json_snippet)

View File

@ -86,16 +86,18 @@ class StackResource(resource.Resource):
self._resolve_all_attributes) self._resolve_all_attributes)
def _needs_update(self, after, before, after_props, before_props, 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 # Issue an update to the nested stack if the stack resource
# is able to update. If return true, let the individual # is able to update. If return true, let the individual
# resources in it decide if they need updating. # resources in it decide if they need updating.
# FIXME (ricolin): seems currently can not call super here # FIXME (ricolin): seems currently can not call super here
if self.nested() is None and ( if self.nested() is None and self.status == self.FAILED:
self.status == self.FAILED raise resource.UpdateReplace(self)
or (self.action == self.INIT
and self.status == self.COMPLETE)): if (check_init_complete and
self.nested() is None and
self.action == self.INIT and self.status == self.COMPLETE):
raise resource.UpdateReplace(self) raise resource.UpdateReplace(self)
return True return True

View File

@ -57,6 +57,7 @@ from heat.engine import stack as parser
from heat.engine import stack_lock from heat.engine import stack_lock
from heat.engine import support from heat.engine import support
from heat.engine import template as templatem from heat.engine import template as templatem
from heat.engine import update
from heat.engine import watchrule from heat.engine import watchrule
from heat.engine import worker from heat.engine import worker
from heat.objects import event as event_object from heat.objects import event as event_object
@ -272,7 +273,7 @@ class EngineService(service.Service):
by the RPC caller. by the RPC caller.
""" """
RPC_API_VERSION = '1.14' RPC_API_VERSION = '1.15'
def __init__(self, host, topic): def __init__(self, host, topic):
super(EngineService, self).__init__() super(EngineService, self).__init__()
@ -717,6 +718,46 @@ class EngineService(service.Service):
return dict(stack.identifier()) 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 @context.request_context
def update_stack(self, cnxt, stack_identity, template, params, def update_stack(self, cnxt, stack_identity, template, params,
files, args): files, args):
@ -767,29 +808,11 @@ class EngineService(service.Service):
new_env = environment.Environment(params) new_env = environment.Environment(params)
new_files = files new_files = files
tmpl = templatem.Template(template, files=new_files, env=new_env) 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) current_stack, updated_stack = self._prepare_stack_updates(
common_params.setdefault(rpc_api.PARAM_TIMEOUT, cnxt, current_stack, tmpl, params, files, args)
current_stack.timeout_mins)
common_params.setdefault(rpc_api.PARAM_DISABLE_ROLLBACK,
current_stack.disable_rollback)
current_kwargs.update(common_params) if current_stack.get_kwargs_for_cloning()['convergence']:
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']:
current_stack.converge_stack(template=tmpl, current_stack.converge_stack(template=tmpl,
new_stack=updated_stack) new_stack=updated_stack)
else: else:
@ -804,6 +827,57 @@ class EngineService(service.Service):
self.thread_group_mgr.add_event(current_stack.id, event) self.thread_group_mgr.add_event(current_stack.id, event)
return dict(current_stack.identifier()) 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 @context.request_context
def stack_cancel_update(self, cnxt, stack_identity, def stack_cancel_update(self, cnxt, stack_identity,
cancel_with_rollback=True): cancel_with_rollback=True):

View File

@ -219,3 +219,43 @@ class StackUpdate(object):
yield (res, self.new_stack[name]) yield (res, self.new_stack[name])
return dependencies.Dependencies(edges()) 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,
}

View File

@ -35,6 +35,7 @@ class EngineClient(object):
1.12 - Add with_detail option for stack resources list 1.12 - Add with_detail option for stack resources list
1.13 - Add support for template functions list 1.13 - Add support for template functions list
1.14 - Add cancel_with_rollback option to stack_cancel_update 1.14 - Add cancel_with_rollback option to stack_cancel_update
1.15 - Add preview_update_stack() call
''' '''
BASE_RPC_API_VERSION = '1.0' BASE_RPC_API_VERSION = '1.0'
@ -264,6 +265,32 @@ class EngineClient(object):
files=files, files=files,
args=args)) 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): def validate_template(self, ctxt, template, params=None):
""" """
The validate_template method uses the stack parser to check The validate_template method uses the stack parser to check

View File

@ -1101,6 +1101,47 @@ class StackControllerTest(tools.ControllerTest, common.HeatTestCase):
self.assertEqual({'stack': 'formatted_stack'}, result) 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): def test_lookup(self, mock_enforce):
self._mock_enforce_setup(mock_enforce, 'lookup', True) self._mock_enforce_setup(mock_enforce, 'lookup', True)
identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '1') identity = identifier.HeatIdentifier(self.tenant, 'wordpress', '1')

View File

@ -39,7 +39,7 @@ class ServiceEngineTest(common.HeatTestCase):
def test_make_sure_rpc_version(self): def test_make_sure_rpc_version(self):
self.assertEqual( self.assertEqual(
'1.14', '1.15',
service.EngineService.RPC_API_VERSION, service.EngineService.RPC_API_VERSION,
('RPC version is changed, please update this test to new version ' ('RPC version is changed, please update this test to new version '
'and make sure additional test cases are added for RPC APIs ' 'and make sure additional test cases are added for RPC APIs '

View File

@ -894,6 +894,209 @@ class StackServiceAdoptUpdateTest(common.HeatTestCase):
self.m.VerifyAll() 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): class StackConvergenceServiceCreateUpdateTest(common.HeatTestCase):

View File

@ -175,6 +175,14 @@ class EngineRpcAPITestCase(common.HeatTestCase):
files={}, files={},
args=mock.ANY) 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): def test_get_template(self):
self._test_engine_api('get_template', 'call', self._test_engine_api('get_template', 'call',
stack_identity=self.identity) stack_identity=self.identity)