diff options
-rw-r--r-- | heatclient/common/utils.py | 25 | ||||
-rw-r--r-- | heatclient/osc/v1/stack.py | 81 | ||||
-rw-r--r-- | heatclient/tests/unit/osc/v1/test_stack.py | 96 | ||||
-rw-r--r-- | heatclient/tests/unit/test_utils.py | 11 | ||||
-rw-r--r-- | setup.cfg | 1 |
5 files changed, 213 insertions, 1 deletions
diff --git a/heatclient/common/utils.py b/heatclient/common/utils.py index a36ab38..b646e59 100644 --- a/heatclient/common/utils.py +++ b/heatclient/common/utils.py @@ -17,6 +17,7 @@ import base64 import logging import os import textwrap +import time import uuid from oslo_serialization import jsonutils @@ -115,6 +116,30 @@ def event_log_formatter(events): return "\n".join(event_log) +def wait_for_delete(status_f, + res_id, + status_field='status', + sleep_time=5, + timeout=300): + """Wait for resource deletion.""" + + total_time = 0 + while total_time < timeout: + try: + res = status_f(res_id) + except exc.HTTPNotFound: + return True + + status = res.get(status_field, '').lower() + if 'failed' in status: + return False + + time.sleep(sleep_time) + total_time += sleep_time + + return False + + def print_update_list(lst, fields, formatters=None): """Print the stack-update --dry-run output as a table. diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py index d71f5f7..d0370a5 100644 --- a/heatclient/osc/v1/stack.py +++ b/heatclient/osc/v1/stack.py @@ -14,7 +14,9 @@ """Orchestration v1 Stack action implementations""" import logging +import sys +from cliff import command from cliff import lister from cliff import show from openstackclient.common import exceptions as exc @@ -30,6 +32,7 @@ from heatclient.common import template_utils from heatclient.common import utils as heat_utils from heatclient import exc as heat_exc from heatclient.openstack.common._i18n import _ +from heatclient.openstack.common._i18n import _LI def _authenticated_fetcher(client): @@ -505,7 +508,6 @@ class ListStack(lister.Lister): help=_('List additional fields in output, this is implied by ' '--all-projects') ) - return parser def take_action(self, parsed_args): @@ -572,6 +574,83 @@ def _list(client, args=None): ) +class DeleteStack(command.Command): + """Delete stack(s).""" + + log = logging.getLogger(__name__ + ".DeleteStack") + + def get_parser(self, prog_name): + parser = super(DeleteStack, self).get_parser(prog_name) + parser.add_argument( + 'stack', + metavar='<stack>', + nargs='+', + help=_('Stack(s) to delete (name or ID)') + ) + parser.add_argument( + '--yes', + action='store_true', + help=_('Skip yes/no prompt (assume yes)') + ) + parser.add_argument( + '--wait', + action='store_true', + help=_('Wait for stack delete to complete') + ) + return parser + + def take_action(self, parsed_args): + self.log.debug("take_action(%s)", parsed_args) + + heat_client = self.app.client_manager.orchestration + + try: + if not parsed_args.yes and sys.stdin.isatty(): + sys.stdout.write( + _("Are you sure you want to delete this stack(s) [y/N]? ")) + prompt_response = sys.stdin.readline().lower() + if not prompt_response.startswith('y'): + self.log.info(_LI('User did not confirm stack delete so ' + 'taking no action.')) + return + except KeyboardInterrupt: # ctrl-c + self.log.info(_LI('User did not confirm stack delete ' + '(ctrl-c) so taking no action.')) + return + except EOFError: # ctrl-d + self.log.info(_LI('User did not confirm stack delete ' + '(ctrl-d) so taking no action.')) + return + + failure_count = 0 + stacks_waiting = [] + for sid in parsed_args.stack: + try: + heat_client.stacks.delete(sid) + stacks_waiting.append(sid) + except heat_exc.HTTPNotFound: + failure_count += 1 + print(_('Stack not found: %s') % sid) + + if parsed_args.wait: + for sid in stacks_waiting: + def status_f(id): + return heat_client.stacks.get(id).to_dict() + + # TODO(jonesbr): switch to use openstack client wait_for_delete + # when version 2.1.0 is adopted. + if not heat_utils.wait_for_delete(status_f, + sid, + status_field='stack_status'): + failure_count += 1 + print(_('Stack failed to delete: %s') % sid) + + if failure_count: + msg = (_('Unable to delete %(count)d of the %(total)d stacks.') % + {'count': failure_count, 'total': len(parsed_args.stack)}) + raise exc.CommandError(msg) + + class AdoptStack(show.ShowOne): """Adopt a stack.""" diff --git a/heatclient/tests/unit/osc/v1/test_stack.py b/heatclient/tests/unit/osc/v1/test_stack.py index 161de58..7a1fd36 100644 --- a/heatclient/tests/unit/osc/v1/test_stack.py +++ b/heatclient/tests/unit/osc/v1/test_stack.py @@ -527,6 +527,102 @@ class TestStackList(TestStack): self.assertRaises(exc.CommandError, self.cmd.take_action, parsed_args) +class TestStackDelete(TestStack): + + def setUp(self): + super(TestStackDelete, self).setUp() + self.cmd = stack.DeleteStack(self.app, None) + self.stack_client.delete = mock.MagicMock() + self.stack_client.get = mock.MagicMock( + side_effect=heat_exc.HTTPNotFound) + + def test_stack_delete(self): + arglist = ['stack1', 'stack2', 'stack3'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.delete.assert_any_call('stack1') + self.stack_client.delete.assert_any_call('stack2') + self.stack_client.delete.assert_any_call('stack3') + + def test_stack_delete_not_found(self): + arglist = ['my_stack'] + self.stack_client.delete.side_effect = heat_exc.HTTPNotFound + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.assertRaises(exc.CommandError, self.cmd.take_action, parsed_args) + + def test_stack_delete_one_found_one_not_found(self): + arglist = ['stack1', 'stack2'] + self.stack_client.delete.side_effect = [None, heat_exc.HTTPNotFound] + parsed_args = self.check_parser(self.cmd, arglist, []) + + error = self.assertRaises(exc.CommandError, + self.cmd.take_action, parsed_args) + + self.stack_client.delete.assert_any_call('stack1') + self.stack_client.delete.assert_any_call('stack2') + self.assertEqual('Unable to delete 1 of the 2 stacks.', str(error)) + + def test_stack_delete_wait(self): + arglist = ['stack1', 'stack2', 'stack3', '--wait'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.delete.assert_any_call('stack1') + self.stack_client.get.assert_any_call('stack1') + self.stack_client.delete.assert_any_call('stack2') + self.stack_client.get.assert_any_call('stack2') + self.stack_client.delete.assert_any_call('stack3') + self.stack_client.get.assert_any_call('stack3') + + def test_stack_delete_wait_one_pass_one_fail(self): + arglist = ['stack1', 'stack2', 'stack3', '--wait'] + self.stack_client.get.side_effect = [ + stacks.Stack(None, {'stack_status': 'DELETE_FAILED'}), + heat_exc.HTTPNotFound, + stacks.Stack(None, {'stack_status': 'DELETE_FAILED'}), + ] + parsed_args = self.check_parser(self.cmd, arglist, []) + + error = self.assertRaises(exc.CommandError, + self.cmd.take_action, parsed_args) + + self.stack_client.delete.assert_any_call('stack1') + self.stack_client.get.assert_any_call('stack1') + self.stack_client.delete.assert_any_call('stack2') + self.stack_client.get.assert_any_call('stack2') + self.stack_client.delete.assert_any_call('stack3') + self.stack_client.get.assert_any_call('stack3') + self.assertEqual('Unable to delete 2 of the 3 stacks.', str(error)) + + @mock.patch('sys.stdin', spec=six.StringIO) + def test_stack_delete_prompt(self, mock_stdin): + arglist = ['my_stack'] + mock_stdin.isatty.return_value = True + mock_stdin.readline.return_value = 'y' + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + mock_stdin.readline.assert_called_with() + self.stack_client.delete.assert_called_with('my_stack') + + @mock.patch('sys.stdin', spec=six.StringIO) + def test_stack_delete_prompt_no(self, mock_stdin): + arglist = ['my_stack'] + mock_stdin.isatty.return_value = True + mock_stdin.readline.return_value = 'n' + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + mock_stdin.readline.assert_called_with() + self.stack_client.delete.assert_not_called() + + class TestStackAdopt(TestStack): adopt_file = 'heatclient/tests/test_templates/adopt.json' diff --git a/heatclient/tests/unit/test_utils.py b/heatclient/tests/unit/test_utils.py index 8237b4f..c58f788 100644 --- a/heatclient/tests/unit/test_utils.py +++ b/heatclient/tests/unit/test_utils.py @@ -188,6 +188,17 @@ class ShellTest(testtools.TestCase): self.assertEqual(expected, utils.event_log_formatter(events_list)) self.assertEqual('', utils.event_log_formatter([])) + def test_wait_for_delete(self): + def status_f(id): + raise exc.HTTPNotFound + + def bad_status_f(id): + return {'status': 'failed'} + + self.assertTrue(utils.wait_for_delete(status_f, 123)) + self.assertFalse(utils.wait_for_delete(status_f, 123, timeout=0)) + self.assertFalse(utils.wait_for_delete(bad_status_f, 123)) + class ShellTestParameterFiles(testtools.TestCase): @@ -43,6 +43,7 @@ openstack.orchestration.v1 = stack_adopt = heatclient.osc.v1.stack:AdoptStack stack_check = heatclient.osc.v1.stack:CheckStack stack_create = heatclient.osc.v1.stack:CreateStack + stack_delete = heatclient.osc.v1.stack:DeleteStack stack_list = heatclient.osc.v1.stack:ListStack stack_output_list = heatclient.osc.v1.stack:OutputListStack stack_output_show = heatclient.osc.v1.stack:OutputShowStack |