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: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",

View File

@ -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}',

View File

@ -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):
"""

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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,
}

View File

@ -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

View File

@ -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')

View File

@ -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 '

View File

@ -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):

View File

@ -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)