diff --git a/doc/source/template_guide/environment.rst b/doc/source/template_guide/environment.rst index 58fb39f394..a34b951c54 100644 --- a/doc/source/template_guide/environment.rst +++ b/doc/source/template_guide/environment.rst @@ -191,3 +191,37 @@ You can specify endpoints using the ``event_sinks`` property:: - type: zaqar-queue target: myqueue ttl: 1200 + +Restrict update or replace of a given resource +----------------------------------------------- +If you want to restrict update or replace of a resource when your stack is +being updated, you can set ``restricted_actions`` in the ``resources`` +section of ``resource_registry``. + +To restrict update or replace, add ``restricted_actions: update`` or +``restricted_actions: replace`` to the resource dictionary. You can also +use ``[update, replace]`` to restrict both actions. + +You can combine restrcited actions with other ``resources`` properties such +as provider templates or type mapping or hooks:: + + resource_registry: + resources: + my_server: + "OS::DBInstance": file:///home/mine/all_my_cool_templates/db.yaml + restricted_actions: replace + hooks: pre-create + nested_stack: + nested_resource: + restricted_actions: update + another_resource: + restricted_actions: [update, replace] + +It is possible to perform a wild card match using an asterisk (`*`) in the +resource name. For example, the following entry restricts replace for +``app_server`` and ``database_server``, but not ``server`` or ``app_network``:: + + resource_registry: + resources: + "*_server": + restricted_actions: replace diff --git a/heat/common/exception.py b/heat/common/exception.py index 66bede06d0..50c3fe59b9 100644 --- a/heat/common/exception.py +++ b/heat/common/exception.py @@ -236,6 +236,10 @@ class InvalidBreakPointHook(HeatException): msg_fmt = _("%(message)s") +class InvalidRestrictedAction(HeatException): + msg_fmt = _("%(message)s") + + class ResourceNotAvailable(HeatException): msg_fmt = _("The Resource (%(resource_name)s) is not available.") @@ -312,6 +316,10 @@ class ResourceActionNotSupported(HeatException): msg_fmt = _("%(action)s is not supported for resource.") +class ResourceActionRestricted(HeatException): + msg_fmt = _("%(action)s is restricted for resource.") + + class ResourcePropertyConflict(HeatException): msg_fmt = _('Cannot define the following properties ' 'at the same time: %(props)s.') diff --git a/heat/engine/environment.py b/heat/engine/environment.py index b3db5e481e..86b13bb242 100644 --- a/heat/engine/environment.py +++ b/heat/engine/environment.py @@ -40,11 +40,17 @@ HOOK_TYPES = ( 'pre-create', 'pre-update', 'pre-delete' ) +RESTRICTED_ACTIONS = (UPDATE, REPLACE) = ('update', 'replace') + def valid_hook_type(hook): return hook in HOOK_TYPES +def valid_restricted_actions(action): + return action in RESTRICTED_ACTIONS + + def is_hook_definition(key, value): is_valid_hook = False if key == 'hooks': @@ -62,6 +68,25 @@ def is_hook_definition(key, value): return is_valid_hook +def is_valid_restricted_action(key, value): + valid_action = False + if key == 'restricted_actions': + if isinstance(value, six.string_types): + valid_action = valid_restricted_actions(value) + elif isinstance(value, collections.Sequence): + valid_action = all(valid_restricted_actions( + action) for action in value) + + if not valid_action: + msg = (_('Invalid restricted_action type "%(value)s" for ' + 'resource, acceptable restricted_action ' + 'types are: %(types)s') % + {'value': value, 'types': RESTRICTED_ACTIONS}) + raise exception.InvalidRestrictedAction(message=msg) + + return valid_action + + class ResourceInfo(object): """Base mapping of resource type to implementation.""" @@ -239,22 +264,22 @@ class ResourceRegistry(object): for k, v in iter(registry.items()): if v is None: self._register_info(path + [k], None) - elif is_hook_definition(k, v): - self._register_hook(path + [k], v) + elif is_hook_definition(k, v) or is_valid_restricted_action(k, v): + self._register_item(path + [k], v) elif isinstance(v, dict): self._load_registry(path + [k], v) else: self._register_info(path + [k], ResourceInfo(self, path + [k], v)) - def _register_hook(self, path, hook): + def _register_item(self, path, item): name = path[-1] registry = self._registry for key in path[:-1]: if key not in registry: registry[key] = {} registry = registry[key] - registry[name] = hook + registry[name] = item def _register_info(self, path, info): """Place the new info in the correct location in the registry. @@ -326,6 +351,33 @@ class ResourceRegistry(object): if info.path[-1] in registry: registry.pop(info.path[-1]) + def get_rsrc_restricted_actions(self, resource_name): + """Returns a set of restricted actions. + + For a given resource we get the set of restricted actions. + + Actions are set in this format via `resources`: + + { + "restricted_actions": [update, replace] + } + + A restricted_actions value is either `update`, `replace` or a list + of those values. Resources support wildcard matching. The asterisk + sign matches everything. + """ + ress = self._registry['resources'] + restricted_actions = set() + for name_pattern, resource in six.iteritems(ress): + if fnmatch.fnmatchcase(resource_name, name_pattern): + if 'restricted_actions' in resource: + actions = resource['restricted_actions'] + if isinstance(actions, six.string_types): + restricted_actions.add(actions) + elif isinstance(actions, collections.Sequence): + restricted_actions |= set(actions) + return restricted_actions + def matches_hook(self, resource_name, hook): """Return whether a resource have a hook set in the environment. @@ -491,7 +543,8 @@ class ResourceRegistry(object): for k, v in iter(level.items()): if isinstance(v, dict): tmp[k] = _as_dict(v) - elif is_hook_definition(k, v): + elif is_hook_definition( + k, v) or is_valid_restricted_action(k, v): tmp[k] = v elif v.user_resource: tmp[k] = v.value diff --git a/heat/engine/resource.py b/heat/engine/resource.py index 84282df461..2b72cac6ec 100644 --- a/heat/engine/resource.py +++ b/heat/engine/resource.py @@ -930,6 +930,46 @@ class Resource(object): update_tmpl_id_and_requires() raise + def preview_update(self, after, before, after_props, before_props, + prev_resource, check_init_complete=False): + """Simulates update without actually updating the resource. + + Raises UpdateReplace, if replacement is required or returns True, + if in-place update is required. + """ + if self._needs_update(after, before, after_props, before_props, + prev_resource, check_init_complete): + tmpl_diff = self.update_template_diff(function.resolve(after), + before) + if tmpl_diff and self.needs_replace_with_tmpl_diff(tmpl_diff): + raise exception.UpdateReplace(self) + + self.update_template_diff_properties(after_props, + before_props) + return True + + def _check_restricted_actions(self, actions, after, before, + after_porps, before_props, + prev_resource): + """Checks for restricted actions. + + Raises ResourceActionRestricted, if the resource requires update + or replace and the required action is restricted. + + Else, Raises UpdateReplace, if replacement is required or returns + True, if in-place update is required. + """ + try: + if self.preview_update(after, before, after_porps, before_props, + prev_resource, check_init_complete=True): + if 'update' in actions: + raise exception.ResourceActionRestricted(action='update') + return True + except exception.UpdateReplace: + if 'replace' in actions: + raise exception.ResourceActionRestricted(action='replace') + raise + @scheduler.wrappertask def update(self, after, before=None, prev_resource=None): """Return a task to update the resource. @@ -974,9 +1014,21 @@ class Resource(object): self.UPDATE, environment.HOOK_PRE_UPDATE) try: - if not self._needs_update(after, before, after_props, before_props, - prev_resource): - return + registry = self.stack.env.registry + restr_actions = registry.get_rsrc_restricted_actions(self.name) + if restr_actions: + if not self._check_restricted_actions(restr_actions, + after, before, + after_props, + before_props, + prev_resource): + return + else: + if not self._needs_update(after, before, + after_props, before_props, + prev_resource): + return + if not cfg.CONF.convergence_engine: if (self.action, self.status) in ( (self.CREATE, self.IN_PROGRESS), @@ -988,6 +1040,7 @@ class Resource(object): LOG.info(_LI('updating %s'), six.text_type(self)) self.updated_time = datetime.utcnow() + with self._action_recorder(action, exception.UpdateReplace): after_props.validate() @@ -1006,8 +1059,14 @@ class Resource(object): self.t = after self.reparse() self._update_stored_properties() + + except exception.ResourceActionRestricted as ae: + # catch all ResourceActionRestricted exceptions + failure = exception.ResourceFailure(ae, self, action) + self._add_event(action, self.FAILED, six.text_type(ae)) + raise failure except exception.UpdateReplace: - # catch all UpdateReplace expections + # catch all UpdateReplace exceptions try: if (self.stack.action == 'ROLLBACK' and self.stack.status == 'IN_PROGRESS' and diff --git a/heat/engine/update.py b/heat/engine/update.py index 6d2af09400..e528aab4a9 100644 --- a/heat/engine/update.py +++ b/heat/engine/update.py @@ -132,32 +132,47 @@ class StackUpdate(object): yield new_res.create() + def _check_replace_restricted(self, res): + registry = res.stack.env.registry + restricted_actions = registry.get_rsrc_restricted_actions(res.name) + existing_res = self.existing_stack[res.name] + if 'replace' in restricted_actions: + ex = exception.ResourceActionRestricted(action='replace') + failure = exception.ResourceFailure(ex, existing_res, + existing_res.UPDATE) + existing_res._add_event(existing_res.UPDATE, existing_res.FAILED, + six.text_type(ex)) + raise failure + @scheduler.wrappertask def _process_new_resource_update(self, new_res): res_name = new_res.name - if (res_name in self.existing_stack and - type(self.existing_stack[res_name]) is type(new_res)): - existing_res = self.existing_stack[res_name] - try: - yield self._update_in_place(existing_res, - new_res) - except exception.UpdateReplace: - pass - else: - # Save updated resource definition to backup stack - # cause it allows the backup stack resources to be synchronized - LOG.debug("Backing up updated Resource %s" % res_name) - definition = existing_res.t.reparse(self.previous_stack, - existing_res.stack.t) - self.previous_stack.t.add_resource(definition) - self.previous_stack.t.store(self.previous_stack.context) + if res_name in self.existing_stack: + if type(self.existing_stack[res_name]) is type(new_res): + existing_res = self.existing_stack[res_name] + try: + yield self._update_in_place(existing_res, + new_res) + except exception.UpdateReplace: + pass + else: + # Save updated resource definition to backup stack + # cause it allows the backup stack resources to be + # synchronized + LOG.debug("Backing up updated Resource %s" % res_name) + definition = existing_res.t.reparse(self.previous_stack, + existing_res.stack.t) + self.previous_stack.t.add_resource(definition) + self.previous_stack.t.store(self.previous_stack.context) - LOG.info(_LI("Resource %(res_name)s for stack %(stack_name)s " - "updated"), - {'res_name': res_name, - 'stack_name': self.existing_stack.name}) - return + LOG.info(_LI("Resource %(res_name)s for stack " + "%(stack_name)s updated"), + {'res_name': res_name, + 'stack_name': self.existing_stack.name}) + return + else: + self._check_replace_restricted(new_res) yield self._create_resource(new_res) @@ -244,12 +259,10 @@ class StackUpdate(object): continue 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) + if current_res.preview_update(updated_res.frozen_definition(), + current_res.frozen_definition(), + updated_props, current_props, + None): updated_keys.append(key) except exception.UpdateReplace: replaced_keys.append(key) diff --git a/heat/tests/test_environment.py b/heat/tests/test_environment.py index f4be9e9d17..094a13d06e 100644 --- a/heat/tests/test_environment.py +++ b/heat/tests/test_environment.py @@ -961,3 +961,101 @@ class HookMatchTest(common.HeatTestCase): 'all', environment.HOOK_PRE_UPDATE)) self.assertTrue(registry.matches_hook( 'all', environment.HOOK_PRE_DELETE)) + + +class ActionRestrictedTest(common.HeatTestCase): + + def test_plain_matches(self): + resources = { + u'a': { + u'OS::Fruit': u'apples.yaml', + u'restricted_actions': [u'update', u'replace'], + }, + u'b': { + u'OS::Food': u'fruity.yaml', + }, + u'nested': { + u'res': { + u'restricted_actions': 'update', + }, + }, + } + registry = environment.ResourceRegistry(None, {}) + registry.load({ + u'OS::Fruit': u'apples.yaml', + 'resources': resources}) + + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('a')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('b')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('OS::Fruit')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('res')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('unknown')) + + def test_wildcard_matches(self): + resources = { + u'prefix_*': { + u'restricted_actions': 'update', + }, + u'*_suffix': { + u'restricted_actions': 'update', + }, + u'*': { + u'restricted_actions': 'replace', + }, + } + registry = environment.ResourceRegistry(None, {}) + registry.load({'resources': resources}) + + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('prefix_')) + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('prefix_some')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('some_prefix')) + + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('_suffix')) + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('some_suffix')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('_suffix_blah')) + + self.assertIn(environment.REPLACE, + registry.get_rsrc_restricted_actions('some_prefix')) + self.assertIn(environment.REPLACE, + registry.get_rsrc_restricted_actions('_suffix_blah')) + + def test_restricted_action_types(self): + resources = { + u'update': { + u'restricted_actions': 'update', + }, + u'replace': { + u'restricted_actions': 'replace', + }, + u'all': { + u'restricted_actions': ['update', 'replace'], + }, + } + registry = environment.ResourceRegistry(None, {}) + registry.load({'resources': resources}) + + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('update')) + self.assertNotIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('replace')) + + self.assertIn(environment.REPLACE, + registry.get_rsrc_restricted_actions('replace')) + self.assertNotIn(environment.REPLACE, + registry.get_rsrc_restricted_actions('update')) + + self.assertIn(environment.UPDATE, + registry.get_rsrc_restricted_actions('all')) + self.assertIn(environment.REPLACE, + registry.get_rsrc_restricted_actions('all')) diff --git a/heat/tests/test_resource.py b/heat/tests/test_resource.py index 0b3726ba7a..6f5303db85 100644 --- a/heat/tests/test_resource.py +++ b/heat/tests/test_resource.py @@ -36,6 +36,8 @@ from heat.engine import environment from heat.engine import properties from heat.engine import resource from heat.engine import resources +from heat.engine.resources.openstack.heat import none_resource +from heat.engine.resources.openstack.heat import test_resource from heat.engine import rsrc_defn from heat.engine import scheduler from heat.engine import stack as parser @@ -3399,3 +3401,127 @@ class TestLiveStateUpdate(common.HeatTestCase): # schema for correct work of other tests. for prop in six.itervalues(res.properties.props): prop.schema.update_allowed = False + + +class ResourceUpdateRestrictionTest(common.HeatTestCase): + def setUp(self): + super(ResourceUpdateRestrictionTest, self).setUp() + resource._register_class('TestResourceType', + test_resource.TestResource) + resource._register_class('NoneResourceType', + none_resource.NoneResource) + self.tmpl = { + 'heat_template_version': '2013-05-23', + 'resources': { + 'bar': { + 'type': 'TestResourceType', + 'properties': { + 'value': '1234', + 'update_replace': False + } + } + } + } + + def create_resource(self): + self.stack = parser.Stack(utils.dummy_context(), 'test_stack', + template.Template(self.tmpl, env=self.env), + stack_id=str(uuid.uuid4())) + res = self.stack['bar'] + scheduler.TaskRunner(res.create)() + return res + + def test_update_restricted(self): + self.env_snippet = {u'resource_registry': { + u'resources': { + 'bar': {'restricted_actions': 'update'} + } + } + } + self.env = environment.Environment() + self.env.load(self.env_snippet) + res = self.create_resource() + ev = self.patchobject(res, '_add_event') + props = self.tmpl['resources']['bar']['properties'] + props['value'] = '4567' + snippet = rsrc_defn.ResourceDefinition('bar', + 'TestResourceType', + props) + error = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(res.update, snippet)) + + self.assertEqual('ResourceActionRestricted: resources.bar: ' + 'update is restricted for resource.', + six.text_type(error)) + self.assertEqual((res.CREATE, res.COMPLETE), res.state) + ev.assert_called_with(res.UPDATE, res.FAILED, + 'update is restricted for resource.') + + def test_replace_rstricted(self): + self.env_snippet = {u'resource_registry': { + u'resources': { + 'bar': {'restricted_actions': 'replace'} + } + } + } + self.env = environment.Environment() + self.env.load(self.env_snippet) + res = self.create_resource() + ev = self.patchobject(res, '_add_event') + props = self.tmpl['resources']['bar']['properties'] + props['update_replace'] = True + snippet = rsrc_defn.ResourceDefinition('bar', + 'TestResourceType', + props) + error = self.assertRaises(exception.ResourceFailure, + scheduler.TaskRunner(res.update, snippet)) + self.assertEqual('ResourceActionRestricted: resources.bar: ' + 'replace is restricted for resource.', + six.text_type(error)) + self.assertEqual((res.CREATE, res.COMPLETE), res.state) + ev.assert_called_with(res.UPDATE, res.FAILED, + 'replace is restricted for resource.') + + def test_update_with_replace_rstricted(self): + self.env_snippet = {u'resource_registry': { + u'resources': { + 'bar': {'restricted_actions': 'replace'} + } + } + } + self.env = environment.Environment() + self.env.load(self.env_snippet) + res = self.create_resource() + ev = self.patchobject(res, '_add_event') + props = self.tmpl['resources']['bar']['properties'] + props['value'] = '4567' + snippet = rsrc_defn.ResourceDefinition('bar', + 'TestResourceType', + props) + self.assertIsNone(scheduler.TaskRunner(res.update, snippet)()) + self.assertEqual((res.UPDATE, res.COMPLETE), res.state) + ev.assert_called_with(res.UPDATE, res.COMPLETE, + 'state changed') + + def test_replace_with_update_rstricted(self): + self.env_snippet = {u'resource_registry': { + u'resources': { + 'bar': {'restricted_actions': 'update'} + } + } + } + self.env = environment.Environment() + self.env.load(self.env_snippet) + res = self.create_resource() + ev = self.patchobject(res, '_add_event') + prep_replace = self.patchobject(res, 'prepare_for_replace') + props = self.tmpl['resources']['bar']['properties'] + props['update_replace'] = True + snippet = rsrc_defn.ResourceDefinition('bar', + 'TestResourceType', + props) + error = self.assertRaises(exception.UpdateReplace, + scheduler.TaskRunner(res.update, snippet)) + self.assertIn('requires replacement', six.text_type(error)) + self.assertEqual(1, prep_replace.call_count) + ev.assert_not_called() diff --git a/heat/tests/test_stack_update.py b/heat/tests/test_stack_update.py index 58f4416fcf..fd7c67af51 100644 --- a/heat/tests/test_stack_update.py +++ b/heat/tests/test_stack_update.py @@ -262,6 +262,49 @@ class StackUpdateTest(common.HeatTestCase): stored_props = loaded_stack['AResource']._stored_properties_data self.assertEqual({'Foo': 'xyz'}, stored_props) + def test_update_replace_resticted(self): + env = environment.Environment() + env_snippet = {u'resource_registry': { + u'resources': { + 'AResource': {'restricted_actions': 'update'} + } + } + } + env.load(env_snippet) + tmpl = {'HeatTemplateFormatVersion': '2012-12-12', + 'Resources': {'AResource': {'Type': 'ResourceWithPropsType', + 'Properties': {'Foo': 'abc'}}}} + + self.stack = stack.Stack(self.ctx, 'update_test_stack', + template.Template(tmpl)) + self.stack.store() + self.stack.create() + self.assertEqual((stack.Stack.CREATE, stack.Stack.COMPLETE), + self.stack.state) + + tmpl1 = {'HeatTemplateFormatVersion': '2012-12-12', + 'Resources': {'AResource': {'Type': 'ResourceWithPropsType', + 'Properties': {'Foo': 'xyz'}}}} + + updated_stack = stack.Stack(self.ctx, 'updated_stack', + template.Template(tmpl1, env=env)) + + self.stack.update(updated_stack) + self.assertEqual((stack.Stack.UPDATE, stack.Stack.COMPLETE), + self.stack.state) + + tmpl2 = {'HeatTemplateFormatVersion': '2012-12-12', + 'Resources': {'AResource': {'Type': 'GenericResourceType'}}} + env_snippet['resource_registry']['resources'][ + 'AResource']['restricted_actions'] = 'replace' + env.load(env_snippet) + updated_stack = stack.Stack(self.ctx, 'updated_stack', + template.Template(tmpl2, env=env)) + + self.stack.update(updated_stack) + self.assertEqual((stack.Stack.UPDATE, stack.Stack.FAILED), + self.stack.state) + def test_update_modify_ok_replace_int(self): # create # ========