diff --git a/doc/source/command-objects/server.rst b/doc/source/command-objects/server.rst index f9ea590828..10ab306502 100644 --- a/doc/source/command-objects/server.rst +++ b/doc/source/command-objects/server.rst @@ -157,7 +157,11 @@ Delete server(s) .. code:: bash os server delete - <server> [<server> ...] + <server> [<server> ...] [--wait] + +.. option:: --wait + + Wait for delete to complete .. describe:: <server> diff --git a/openstackclient/common/utils.py b/openstackclient/common/utils.py index 4139770cd6..aad0519c1b 100644 --- a/openstackclient/common/utils.py +++ b/openstackclient/common/utils.py @@ -283,6 +283,52 @@ def wait_for_status(status_f, return retval +def wait_for_delete(manager, + res_id, + status_field='status', + sleep_time=5, + timeout=300, + callback=None): + """Wait for resource deletion + + :param res_id: the resource id to watch + :param status_field: the status attribute in the returned resource object, + this is used to check for error states while the resource is being + deleted + :param sleep_time: wait this long between checks (seconds) + :param timeout: check until this long (seconds) + :param callback: called per sleep cycle, useful to display progress; this + function is passed a progress value during each iteration of the wait + loop + :rtype: True on success, False if the resource has gone to error state or + the timeout has been reached + """ + total_time = 0 + while total_time < timeout: + try: + # might not be a bad idea to re-use find_resource here if it was + # a bit more friendly in the exceptions it raised so we could just + # handle a NotFound exception here without parsing the message + res = manager.get(res_id) + except Exception as ex: + if type(ex).__name__ == 'NotFound': + return True + raise + + status = getattr(res, status_field, '').lower() + if status == 'error': + return False + + if callback: + progress = getattr(res, 'progress', None) or 0 + callback(progress) + time.sleep(sleep_time) + total_time += sleep_time + + # if we got this far we've timed out + return False + + def get_effective_log_level(): """Returns the lowest logging level considered by logging handlers diff --git a/openstackclient/compute/v2/server.py b/openstackclient/compute/v2/server.py index 41c1b904f6..5007b072ac 100644 --- a/openstackclient/compute/v2/server.py +++ b/openstackclient/compute/v2/server.py @@ -572,6 +572,11 @@ class DeleteServer(command.Command): nargs="+", help=_('Server(s) to delete (name or ID)'), ) + parser.add_argument( + '--wait', + action='store_true', + help=_('Wait for delete to complete'), + ) return parser def take_action(self, parsed_args): @@ -581,6 +586,18 @@ class DeleteServer(command.Command): server_obj = utils.find_resource( compute_client.servers, server) compute_client.servers.delete(server_obj.id) + if parsed_args.wait: + if utils.wait_for_delete( + compute_client.servers, + server_obj.id, + callback=_show_progress, + ): + sys.stdout.write('\n') + else: + self.log.error(_('Error deleting server: %s'), + server_obj.id) + sys.stdout.write(_('\nError deleting server')) + raise SystemExit return diff --git a/openstackclient/tests/common/test_utils.py b/openstackclient/tests/common/test_utils.py index cda0b1351d..d9f5b7a56e 100644 --- a/openstackclient/tests/common/test_utils.py +++ b/openstackclient/tests/common/test_utils.py @@ -13,6 +13,9 @@ # under the License. # +import time +import uuid + import mock from openstackclient.common import exceptions @@ -120,6 +123,42 @@ class TestUtils(test_utils.TestCase): utils.sort_items, items, sort_str) + @mock.patch.object(time, 'sleep') + def test_wait_for_delete_ok(self, mock_sleep): + # Tests the normal flow that the resource is deleted with a 404 coming + # back on the 2nd iteration of the wait loop. + resource = mock.MagicMock(status='ACTIVE', progress=None) + mock_get = mock.Mock(side_effect=[resource, + exceptions.NotFound(404)]) + manager = mock.MagicMock(get=mock_get) + res_id = str(uuid.uuid4()) + callback = mock.Mock() + self.assertTrue(utils.wait_for_delete(manager, res_id, + callback=callback)) + mock_sleep.assert_called_once_with(5) + callback.assert_called_once_with(0) + + @mock.patch.object(time, 'sleep') + def test_wait_for_delete_timeout(self, mock_sleep): + # Tests that we fail if the resource is not deleted before the timeout. + resource = mock.MagicMock(status='ACTIVE') + mock_get = mock.Mock(return_value=resource) + manager = mock.MagicMock(get=mock_get) + res_id = str(uuid.uuid4()) + self.assertFalse(utils.wait_for_delete(manager, res_id, sleep_time=1, + timeout=1)) + mock_sleep.assert_called_once_with(1) + + @mock.patch.object(time, 'sleep') + def test_wait_for_delete_error(self, mock_sleep): + # Tests that we fail if the resource goes to error state while waiting. + resource = mock.MagicMock(status='ERROR') + mock_get = mock.Mock(return_value=resource) + manager = mock.MagicMock(get=mock_get) + res_id = str(uuid.uuid4()) + self.assertFalse(utils.wait_for_delete(manager, res_id)) + self.assertFalse(mock_sleep.called) + class NoUniqueMatch(Exception): pass diff --git a/openstackclient/tests/compute/v2/test_server.py b/openstackclient/tests/compute/v2/test_server.py index baf53742e5..a8a1936d82 100644 --- a/openstackclient/tests/compute/v2/test_server.py +++ b/openstackclient/tests/compute/v2/test_server.py @@ -16,6 +16,7 @@ import copy import mock +from openstackclient.common import utils as common_utils from openstackclient.compute.v2 import server from openstackclient.tests.compute.v2 import fakes as compute_fakes from openstackclient.tests import fakes @@ -319,6 +320,52 @@ class TestServerDelete(TestServer): compute_fakes.server_id, ) + @mock.patch.object(common_utils, 'wait_for_delete', return_value=True) + def test_server_delete_wait_ok(self, mock_wait_for_delete): + arglist = [ + compute_fakes.server_id, '--wait' + ] + verifylist = [ + ('servers', [compute_fakes.server_id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + self.cmd.take_action(parsed_args) + + self.servers_mock.delete.assert_called_with( + compute_fakes.server_id, + ) + + mock_wait_for_delete.assert_called_once_with( + self.servers_mock, + compute_fakes.server_id, + callback=server._show_progress + ) + + @mock.patch.object(common_utils, 'wait_for_delete', return_value=False) + def test_server_delete_wait_fails(self, mock_wait_for_delete): + arglist = [ + compute_fakes.server_id, '--wait' + ] + verifylist = [ + ('servers', [compute_fakes.server_id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # DisplayCommandBase.take_action() returns two tuples + self.assertRaises(SystemExit, self.cmd.take_action, parsed_args) + + self.servers_mock.delete.assert_called_with( + compute_fakes.server_id, + ) + + mock_wait_for_delete.assert_called_once_with( + self.servers_mock, + compute_fakes.server_id, + callback=server._show_progress + ) + class TestServerImageCreate(TestServer):