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='',