From 53c46f0b34cad87c3ed841e438ff610d8c62ab1c Mon Sep 17 00:00:00 2001 From: Steven Hardy Date: Wed, 15 Apr 2015 12:06:10 -0400 Subject: [PATCH] Add hook-poll function to check if a stack has pending hooks Currently using the breakpoints/hooks functionality on large trees of stacks is a bit inconvenient, because you may set hooks in nested stacks so the events aren't easily visible via the top-level event interfaces. This has been addressed via the new --nested-depth option to event-list, but it's still hard to determine (programatically e.g from a script) if there's pending hook which requires clearing. So this patch introduces an initial implementation of a specialized event-list function "hook-poll", which filters the events and displays only those events which have pending hooks (e.g those which have yet to be signalled to clear them). I expect the efficiency of the implementation can be much improved in the future if we add a propert nested_depth argument to the heat events API, and/or add a hook API which enables easier introspection of hook status. The CLI interface should be reasonable in the event such rework happens, but the current (slow) implementation will work with the API interfaces we have available now in kilo Heat. Change-Id: I71b19202ab29f44e5c09b4ee04be4aeaea038c28 --- heatclient/common/event_utils.py | 40 +++++++++ heatclient/tests/test_shell.py | 136 ++++++++++++++++++++++++++++++- heatclient/v1/shell.py | 62 ++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) diff --git a/heatclient/common/event_utils.py b/heatclient/common/event_utils.py index 511a2af7..27685f00 100644 --- a/heatclient/common/event_utils.py +++ b/heatclient/common/event_utils.py @@ -18,6 +18,46 @@ import heatclient.exc as exc from heatclient.openstack.common._i18n import _ +def get_hook_events(hc, stack_id, event_args, nested_depth=0, + hook_type='pre-create'): + if hook_type == 'pre-create': + stack_action_reason = 'Stack CREATE started' + hook_event_reason = 'CREATE paused until Hook pre-create is cleared' + hook_clear_event_reason = 'Hook pre-create is cleared' + elif hook_type == 'pre-update': + stack_action_reason = 'Stack UPDATE started' + hook_event_reason = 'UPDATE paused until Hook pre-update is cleared' + hook_clear_event_reason = 'Hook pre-update is cleared' + else: + raise exc.CommandError(_('Unexpected hook type %s') % hook_type) + + events = get_events(hc, stack_id=stack_id, event_args=event_args, + nested_depth=nested_depth) + + # Get the most recent event associated with this action, which gives us the + # event when we moved into IN_PROGRESS for the hooks we're interested in. + stack_name = stack_id.split("/")[0] + action_start_event = [e for e in enumerate(events) + if e[1].resource_status_reason == stack_action_reason + and e[1].stack_name == stack_name][-1] + # Slice the events with the index from the enumerate + action_start_index = action_start_event[0] + events = events[action_start_index:] + + # Get hook events still pending by some list filtering/comparison + # We build a map hook events per-resource, and remove any event + # for which there is a corresponding hook-clear event. + resource_event_map = {} + for e in events: + stack_resource = (e.stack_name, e.resource_name) + if e.resource_status_reason == hook_event_reason: + resource_event_map[(e.stack_name, e.resource_name)] = e + elif e.resource_status_reason == hook_clear_event_reason: + if resource_event_map.get(stack_resource): + del(resource_event_map[(e.stack_name, e.resource_name)]) + return list(resource_event_map.values()) + + def get_events(hc, stack_id, event_args, nested_depth=0, marker=None, limit=None): events = _get_stack_events(hc, stack_id, event_args) diff --git a/heatclient/tests/test_shell.py b/heatclient/tests/test_shell.py index 87c0dea6..cb5a023c 100644 --- a/heatclient/tests/test_shell.py +++ b/heatclient/tests/test_shell.py @@ -2417,7 +2417,6 @@ class ShellTestEventsNested(ShellBase): 'stack_name', 'teststack', 'nested'] for r in required: self.assertRegexpMatches(list_text, r) - self.assertNotRegexpMatches(list_text, 'p_eventid2') self.assertNotRegexpMatches(list_text, 'n_eventid2') @@ -2425,6 +2424,141 @@ class ShellTestEventsNested(ShellBase): "%s.*\n.*%s.*\n" % timestamps[:2]) +class ShellTestHookPoll(ShellBase): + def setUp(self): + super(ShellTestHookPoll, self).setUp() + self.set_fake_env(FAKE_ENV_KEYSTONE_V2) + + def _stub_stack_response(self, stack_id, action='CREATE', + status='IN_PROGRESS'): + # Stub parent stack show for status + resp_dict = {"stack": { + "id": stack_id.split("/")[1], + "stack_name": stack_id.split("/")[0], + "stack_status": '%s_%s' % (action, status), + "creation_time": "2014-01-06T16:14:00Z", + }} + resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/teststack/1').AndReturn((resp, resp_dict)) + + def _stub_responses(self, stack_id, nested_id, action='CREATE'): + action_reason = 'Stack %s started' % action + hook_reason = ('%s paused until Hook pre-%s is cleared' % + (action, action.lower())) + hook_clear_reason = 'Hook pre-%s is cleared' % action.lower() + + self._stub_stack_response(stack_id, action) + + # Stub events for parent stack + ev_resp_dict = {"events": [{"id": "p_eventid1", + "event_time": "2014-01-06T16:14:00Z", + "resource_name": None, + "resource_status_reason": action_reason}, + {"id": "p_eventid2", + "event_time": "2014-01-06T16:17:00Z", + "resource_name": "p_res", + "resource_status_reason": hook_reason}]} + ev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(ev_resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + stack_id)).AndReturn((ev_resp, ev_resp_dict)) + + # Stub resources for parent, including one nested + res_resp_dict = {"resources": [ + {"links": [{"href": "http://heat/foo", "rel": "self"}, + {"href": "http://heat/foo2", + "rel": "resource"}, + {"href": "http://heat/%s" % nested_id, + "rel": "nested"}], + "resource_type": "OS::Nested::Foo"}]} + res_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(res_resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/%s/resources' % ( + stack_id)).AndReturn((res_resp, res_resp_dict)) + + # Stub the events for the nested stack + nev_resp_dict = {"events": [{"id": 'n_eventid1', + "event_time": "2014-01-06T16:15:00Z", + "resource_name": "n_res", + "resource_status_reason": hook_reason}, + {"id": 'n_eventid2', + "event_time": "2014-01-06T16:16:00Z", + "resource_name": "n_res", + "resource_status_reason": + hook_clear_reason}]} + nev_resp = fakes.FakeHTTPResponse( + 200, + 'OK', + {'content-type': 'application/json'}, + jsonutils.dumps(nev_resp_dict)) + http.HTTPClient.json_request( + 'GET', '/stacks/%s/events?sort_dir=asc' % ( + nested_id)).AndReturn((nev_resp, nev_resp_dict)) + + def test_hook_poll_pre_create(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + self._stub_responses(stack_id, nested_id, 'CREATE') + self.m.ReplayAll() + list_text = self.shell('hook-poll %s --nested-depth 1' % stack_id) + hook_reason = 'CREATE paused until Hook pre-create is cleared' + required = ['id', 'p_eventid2', 'stack_name', 'teststack', hook_reason] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'p_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid2') + + def test_hook_poll_pre_update(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + nested_id = 'nested/2' + self._stub_responses(stack_id, nested_id, 'UPDATE') + self.m.ReplayAll() + list_text = self.shell('hook-poll %s --nested-depth 1' % stack_id) + hook_reason = 'UPDATE paused until Hook pre-update is cleared' + required = ['id', 'p_eventid2', 'stack_name', 'teststack', hook_reason] + for r in required: + self.assertRegexpMatches(list_text, r) + self.assertNotRegexpMatches(list_text, 'p_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid1') + self.assertNotRegexpMatches(list_text, 'n_eventid2') + + def test_hook_poll_bad_status(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self._stub_stack_response(stack_id, status='COMPLETE') + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-poll %s --nested-depth 1' % stack_id) + self.assertIn('Stack status CREATE_COMPLETE not IN_PROGRESS', + str(error)) + + def test_shell_nested_depth_invalid_value(self): + self.register_keystone_auth_fixture() + stack_id = 'teststack/1' + self.m.ReplayAll() + error = self.assertRaises( + exc.CommandError, self.shell, + 'hook-poll %s --nested-depth Z' % stack_id) + self.assertIn('--nested-depth invalid value Z', str(error)) + + class ShellTestResources(ShellBase): def setUp(self): diff --git a/heatclient/v1/shell.py b/heatclient/v1/shell.py index 5f4a7a4a..417ed7cf 100644 --- a/heatclient/v1/shell.py +++ b/heatclient/v1/shell.py @@ -940,6 +940,68 @@ def do_event_list(hc, args): utils.print_list(events, display_fields, sortby_index=None) +@utils.arg('id', metavar='', + help=_('Name or ID of stack to show the pending hooks for.')) +@utils.arg('-n', '--nested-depth', metavar='', + help=_('Depth of nested stacks from which to display hooks.')) +def do_hook_poll(hc, args): + '''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 + display_fields = ['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) + display_fields.append('stack_name') + else: + nested_depth = 0 + + try: + stack = hc.stacks.get(stack_id=args.id) + except exc.HTTPNotFound: + raise exc.CommandError(_('Stack not found: %s') % args.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_action) + + stack_id = args.id + event_args = {'sort_dir': 'asc'} + hook_events = event_utils.get_hook_events( + hc, stack_id=stack_id, event_args=event_args, + nested_depth=nested_depth, hook_type=hook_type) + + if len(hook_events) >= 1: + if hasattr(hook_events[0], 'resource_name'): + display_fields.insert(0, 'resource_name') + else: + display_fields.insert(0, 'logical_resource_id') + + utils.print_list(hook_events, display_fields, sortby_index=None) + + @utils.arg('id', metavar='', help=_('Name or ID of stack to show the events for.')) @utils.arg('resource', metavar='',