diff --git a/heatclient/common/hook_utils.py b/heatclient/common/hook_utils.py new file mode 100644 index 00000000..5cef4342 --- /dev/null +++ b/heatclient/common/hook_utils.py @@ -0,0 +1,78 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fnmatch +import logging + +import heatclient.exc as exc + +from heatclient.openstack.common._i18n import _ +from heatclient.openstack.common._i18n import _LE + +logger = logging.getLogger(__name__) + + +def clear_hook(hc, stack_id, resource_name, hook_type): + try: + hc.resources.signal( + stack_id=stack_id, + resource_name=resource_name, + data={'unset_hook': hook_type}) + except exc.HTTPNotFound: + logger.error( + _LE("Stack %(stack)s or resource %(resource)s" + "not found for hook %(hook_type)"), + {'resource': resource_name, 'stack': stack_id, + 'hook_type': hook_type}) + + +def clear_wildcard_hooks(hc, stack_id, stack_patterns, hook_type, + resource_pattern): + if stack_patterns: + for resource in hc.resources.list(stack_id): + res_name = resource.resource_name + if fnmatch.fnmatchcase(res_name, stack_patterns[0]): + nested_stack = hc.resources.get( + stack_id=stack_id, + resource_name=res_name) + clear_wildcard_hooks( + hc, + nested_stack.physical_resource_id, + stack_patterns[1:], hook_type, resource_pattern) + else: + for resource in hc.resources.list(stack_id): + res_name = resource.resource_name + if fnmatch.fnmatchcase(res_name, resource_pattern): + clear_hook(hc, stack_id, res_name, hook_type) + + +def get_hook_type_via_status(hc, stack_id): + # Figure out if the hook should be pre-create or pre-update based + # on the stack status, also sanity assertions that we're in-progress. + try: + stack = hc.stacks.get(stack_id=stack_id) + except exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') % stack_id) + else: + if 'IN_PROGRESS' not in stack.stack_status: + raise exc.CommandError(_('Stack status %s not IN_PROGRESS') % + stack.stack_status) + + if 'CREATE' in stack.stack_status: + hook_type = 'pre-create' + elif 'UPDATE' in stack.stack_status: + hook_type = 'pre-update' + else: + raise exc.CommandError(_('Unexpected stack status %s, ' + 'only create/update supported') + % stack.stack_status) + return hook_type diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py index 98f417e3..cd67ace6 100644 --- a/heatclient/osc/v1/stack.py +++ b/heatclient/osc/v1/stack.py @@ -26,7 +26,9 @@ from oslo_serialization import jsonutils import six from six.moves.urllib import request +from heatclient.common import event_utils from heatclient.common import format_utils +from heatclient.common import hook_utils from heatclient.common import http from heatclient.common import template_utils from heatclient.common import utils as heat_utils @@ -1060,3 +1062,134 @@ class CheckStack(StackActionBase): ['check_complete'], ['check_failed'] ) + + +class StackHookPoll(lister.Lister): + '''List resources with pending hook for a stack.''' + + log = logging.getLogger(__name__ + '.StackHookPoll') + + def get_parser(self, prog_name): + parser = super(StackHookPoll, self).get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Stack to display (name or ID)') + ) + parser.add_argument( + '--nested-depth', + metavar='', + help=_('Depth of nested stacks from which to display hooks') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + return _hook_poll( + parsed_args, + heat_client + ) + + +def _hook_poll(args, heat_client): + """List resources with pending hook for a stack.""" + + # There are a few steps to determining if a stack has pending hooks + # 1. The stack is IN_PROGRESS status (otherwise, by definition no hooks + # can be pending + # 2. There is an event for a resource associated with hitting a hook + # 3. There is not an event associated with clearing the hook in step(2) + # + # So, essentially, this ends up being a specially filtered type of event + # listing, because all hook status is exposed via events. In future + # we might consider exposing some more efficient interface via the API + # to reduce the expense of this brute-force polling approach + columns = ['ID', 'Resource Status Reason', 'Resource Status', 'Event Time'] + + if args.nested_depth: + try: + nested_depth = int(args.nested_depth) + except ValueError: + msg = _("--nested-depth invalid value %s") % args.nested_depth + raise exc.CommandError(msg) + columns.append('Stack Name') + else: + nested_depth = 0 + + hook_type = hook_utils.get_hook_type_via_status(heat_client, args.stack) + event_args = {'sort_dir': 'asc'} + hook_events = event_utils.get_hook_events( + heat_client, stack_id=args.stack, event_args=event_args, + nested_depth=nested_depth, hook_type=hook_type) + + if len(hook_events) >= 1: + if hasattr(hook_events[0], 'resource_name'): + columns.insert(0, 'Resource Name') + else: + columns.insert(0, 'Logical Resource ID') + + rows = (utils.get_item_properties(h, columns) for h in hook_events) + return (columns, rows) + + +class StackHookClear(command.Command): + """Clear resource hooks on a given stack.""" + + log = logging.getLogger(__name__ + '.StackHookClear') + + def get_parser(self, prog_name): + parser = super(StackHookClear, self).get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='', + help=_('Stack to display (name or ID)') + ) + parser.add_argument( + '--pre-create', + action='store_true', + help=_('Clear the pre-create hooks') + ) + parser.add_argument( + '--pre-update', + action='store_true', + help=_('Clear the pre-update hooks') + ) + parser.add_argument( + 'hook', + metavar='', + nargs='+', + help=_('Resource names with hooks to clear. Resources ' + 'in nested stacks can be set using slash as a separator: ' + 'nested_stack/another/my_resource. You can use wildcards ' + 'to match multiple stacks or resources: ' + 'nested_stack/an*/*_resource') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + heat_client = self.app.client_manager.orchestration + return _hook_clear( + parsed_args, + heat_client + ) + + +def _hook_clear(args, heat_client): + """Clear resource hooks on a given stack.""" + if args.pre_create: + hook_type = 'pre-create' + elif args.pre_update: + hook_type = 'pre-update' + else: + hook_type = hook_utils.get_hook_type_via_status(heat_client, + args.stack) + + for hook_string in args.hook: + hook = [b for b in hook_string.split('/') if b] + resource_pattern = hook[-1] + stack_id = args.stack + + hook_utils.clear_wildcard_hooks(heat_client, stack_id, hook[:-1], + hook_type, resource_pattern) diff --git a/heatclient/tests/unit/osc/v1/test_stack.py b/heatclient/tests/unit/osc/v1/test_stack.py index 7a1fd367..318eca3a 100644 --- a/heatclient/tests/unit/osc/v1/test_stack.py +++ b/heatclient/tests/unit/osc/v1/test_stack.py @@ -26,6 +26,8 @@ from heatclient import exc as heat_exc from heatclient.osc.v1 import stack from heatclient.tests import inline_templates from heatclient.tests.unit.osc.v1 import fakes as orchestration_fakes +from heatclient.v1 import events +from heatclient.v1 import resources from heatclient.v1 import stacks load_tests = testscenarios.load_tests_apply_scenarios @@ -1074,3 +1076,139 @@ class TestStackCheck(_TestStackCheckBase, TestStack): def test_stack_check_exception(self): self._test_stack_action_exception() + + +class TestStackHookPoll(TestStack): + + stack = stacks.Stack(None, { + "id": '1234', + "stack_name": 'my_stack', + "creation_time": "2013-08-04T20:57:55Z", + "updated_time": "2013-08-04T20:57:55Z", + "stack_status": "CREATE_IN_PROGRESS" + }) + resource = resources.Resource(None, { + 'resource_name': 'resource1', + 'links': [{'href': 'http://heat.example.com:8004/resource1', + 'rel': 'self'}, + {'href': 'http://192.168.27.100:8004/my_stack', + 'rel': 'stack'}], + 'logical_resource_id': 'random_group', + 'creation_time': '2015-12-03T16:50:56', + 'resource_status': 'INIT_COMPLETE', + 'updated_time': '2015-12-03T16:50:56', + 'required_by': [], + 'resource_status_reason': '', + 'physical_resource_id': '', + 'resource_type': 'OS::Heat::ResourceGroup', + 'id': '1111' + }) + columns = ['ID', 'Resource Status Reason', 'Resource Status', + 'Event Time'] + event0 = events.Event(manager=None, info={ + 'resource_name': 'my_stack', + 'event_time': '2015-12-02T16:50:56', + 'logical_resource_id': 'my_stack', + 'resource_status': 'CREATE_IN_PROGRESS', + 'resource_status_reason': 'Stack CREATE started', + 'id': '1234' + }) + event1 = events.Event(manager=None, info={ + 'resource_name': 'resource1', + 'event_time': '2015-12-03T19:59:58', + 'logical_resource_id': 'resource1', + 'resource_status': 'INIT_COMPLETE', + 'resource_status_reason': + 'CREATE paused until Hook pre-create is cleared', + 'id': '1111' + }) + row1 = ('resource1', + '1111', + 'CREATE paused until Hook pre-create is cleared', + 'INIT_COMPLETE', + '2015-12-03T19:59:58' + ) + + def setUp(self): + super(TestStackHookPoll, self).setUp() + self.cmd = stack.StackHookPoll(self.app, None) + self.mock_client.stacks.get = mock.Mock( + return_value=self.stack) + self.mock_client.events.list = mock.Mock( + return_value=[self.event0, self.event1]) + self.mock_client.resources.list = mock.Mock( + return_value=[self.resource]) + + def test_hook_poll(self): + expected_columns = ['Resource Name'] + self.columns + expected_rows = [self.row1] + arglist = ['my_stack'] + parsed_args = self.check_parser(self.cmd, arglist, []) + columns, rows = self.cmd.take_action(parsed_args) + self.assertEqual(expected_rows, list(rows)) + self.assertEqual(expected_columns, columns) + + def test_hook_poll_nested(self): + expected_columns = ['Resource Name'] + self.columns + ['Stack Name'] + expected_rows = [self.row1 + ('my_stack',)] + arglist = ['my_stack', '--nested-depth=10'] + parsed_args = self.check_parser(self.cmd, arglist, []) + columns, rows = self.cmd.take_action(parsed_args) + self.assertEqual(expected_rows, list(rows)) + self.assertEqual(expected_columns, columns) + + def test_hook_poll_nested_invalid(self): + arglist = ['my_stack', '--nested-depth=ugly'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.assertRaises(exc.CommandError, self.cmd.take_action, parsed_args) + + +class TestStackHookClear(TestStack): + + stack = stacks.Stack(None, { + "id": '1234', + "stack_name": 'my_stack', + "creation_time": "2013-08-04T20:57:55Z", + "updated_time": "2013-08-04T20:57:55Z", + "stack_status": "CREATE_IN_PROGRESS" + }) + resource = resources.Resource(None, { + 'stack_id': 'my_stack', + 'resource_name': 'resource' + }) + + def setUp(self): + super(TestStackHookClear, self).setUp() + self.cmd = stack.StackHookClear(self.app, None) + self.mock_client.stacks.get = mock.Mock( + return_value=self.stack) + self.mock_client.resources.signal = mock.Mock() + self.mock_client.resources.list = mock.Mock( + return_value=[self.resource]) + + def test_hook_clear(self): + arglist = ['my_stack', 'resource'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.cmd.take_action(parsed_args) + self.mock_client.resources.signal.assert_called_once_with( + data={'unset_hook': 'pre-create'}, + resource_name='resource', + stack_id='my_stack') + + def test_hook_clear_pre_create(self): + arglist = ['my_stack', 'resource', '--pre-create'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.cmd.take_action(parsed_args) + self.mock_client.resources.signal.assert_called_once_with( + data={'unset_hook': 'pre-create'}, + resource_name='resource', + stack_id='my_stack') + + def test_hook_clear_pre_update(self): + arglist = ['my_stack', 'resource', '--pre-update'] + parsed_args = self.check_parser(self.cmd, arglist, []) + self.cmd.take_action(parsed_args) + self.mock_client.resources.signal.assert_called_once_with( + data={'unset_hook': 'pre-update'}, + resource_name='resource', + stack_id='my_stack') diff --git a/heatclient/tests/unit/v1/test_hooks.py b/heatclient/tests/unit/v1/test_hooks.py index 095af908..d2fd947b 100644 --- a/heatclient/tests/unit/v1/test_hooks.py +++ b/heatclient/tests/unit/v1/test_hooks.py @@ -16,6 +16,8 @@ import testtools import heatclient.v1.shell as shell +from heatclient.common import hook_utils + class TestHooks(testtools.TestCase): def setUp(self): @@ -220,7 +222,8 @@ class TestHooks(testtools.TestCase): self.assertEqual(expected_hooks, actual_hooks) def test_clear_all_hooks(self): - shell._get_hook_type_via_status = mock.Mock(return_value='pre-create') + hook_utils.get_hook_type_via_status = mock.Mock( + return_value='pre-create') type(self.args).hook = mock.PropertyMock( return_value=['bp']) type(self.args).pre_create = mock.PropertyMock(return_value=True) diff --git a/heatclient/v1/shell.py b/heatclient/v1/shell.py index a56da63a..b18a82f9 100644 --- a/heatclient/v1/shell.py +++ b/heatclient/v1/shell.py @@ -13,7 +13,6 @@ # License for the specific language governing permissions and limitations # under the License. -import fnmatch import logging from oslo_serialization import jsonutils @@ -25,13 +24,13 @@ import yaml from heatclient.common import deployment_utils from heatclient.common import event_utils +from heatclient.common import hook_utils from heatclient.common import http from heatclient.common import template_format from heatclient.common import template_utils from heatclient.common import utils from heatclient.openstack.common._i18n import _ -from heatclient.openstack.common._i18n import _LE from heatclient.openstack.common._i18n import _LW import heatclient.exc as exc @@ -993,42 +992,15 @@ def do_hook_clear(hc, args): elif args.pre_update: hook_type = 'pre-update' else: - hook_type = _get_hook_type_via_status(hc, args.id) + hook_type = hook_utils.get_hook_type_via_status(hc, args.id) for hook_string in args.hook: hook = [b for b in hook_string.split('/') if b] resource_pattern = hook[-1] stack_id = args.id - def clear_hook(stack_id, resource_name): - try: - hc.resources.signal( - stack_id=stack_id, - resource_name=resource_name, - data={'unset_hook': hook_type}) - except exc.HTTPNotFound: - logger.error( - _LE("Stack %(stack)s or resource %(resource)s not found"), - {'resource': resource_name, 'stack': stack_id}) - - def clear_wildcard_hooks(stack_id, stack_patterns): - if stack_patterns: - for resource in hc.resources.list(stack_id): - res_name = resource.resource_name - if fnmatch.fnmatchcase(res_name, stack_patterns[0]): - nested_stack = hc.resources.get( - stack_id=stack_id, - resource_name=res_name) - clear_wildcard_hooks( - nested_stack.physical_resource_id, - stack_patterns[1:]) - else: - for resource in hc.resources.list(stack_id): - res_name = resource.resource_name - if fnmatch.fnmatchcase(res_name, resource_pattern): - clear_hook(stack_id, res_name) - - clear_wildcard_hooks(stack_id, hook[:-1]) + hook_utils.clear_wildcard_hooks(hc, stack_id, hook[:-1], + hook_type, resource_pattern) @utils.arg('id', metavar='', @@ -1096,29 +1068,6 @@ def do_event_list(hc, args): utils.print_list(events, display_fields, sortby_index=None) -def _get_hook_type_via_status(hc, stack_id): - # Figure out if the hook should be pre-create or pre-update based - # on the stack status, also sanity assertions that we're in-progress. - try: - stack = hc.stacks.get(stack_id=stack_id) - except exc.HTTPNotFound: - raise exc.CommandError(_('Stack not found: %s') % stack_id) - else: - if 'IN_PROGRESS' not in stack.stack_status: - raise exc.CommandError(_('Stack status %s not IN_PROGRESS') % - stack.stack_status) - - if 'CREATE' in stack.stack_status: - hook_type = 'pre-create' - elif 'UPDATE' in stack.stack_status: - hook_type = 'pre-update' - else: - raise exc.CommandError(_('Unexpected stack status %s, ' - 'only create/update supported') - % stack.stack_status) - return hook_type - - @utils.arg('id', metavar='', help=_('Name or ID of stack to show the pending hooks for.')) @utils.arg('-n', '--nested-depth', metavar='', @@ -1148,7 +1097,7 @@ def do_hook_poll(hc, args): else: nested_depth = 0 - hook_type = _get_hook_type_via_status(hc, args.id) + hook_type = hook_utils.get_hook_type_via_status(hc, args.id) event_args = {'sort_dir': 'asc'} hook_events = event_utils.get_hook_events( hc, stack_id=args.id, event_args=event_args, diff --git a/setup.cfg b/setup.cfg index c0024c24..0dc94d07 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,8 @@ openstack.orchestration.v1 = stack_delete = heatclient.osc.v1.stack:DeleteStack stack_event_list = heatclient.osc.v1.event:ListEvent stack_event_show = heatclient.osc.v1.event:ShowEvent + stack_hook_clear = heatclient.osc.v1.stack:StackHookClear + stack_hook_poll = heatclient.osc.v1.stack:StackHookPoll stack_list = heatclient.osc.v1.stack:ListStack stack_output_list = heatclient.osc.v1.stack:OutputListStack stack_output_show = heatclient.osc.v1.stack:OutputShowStack