diff --git a/doc/source/cli/command-objects/server.rst b/doc/source/cli/command-objects/server.rst index 89eb4e3710..cf7df1dae3 100644 --- a/doc/source/cli/command-objects/server.rst +++ b/doc/source/cli/command-objects/server.rst @@ -10,6 +10,9 @@ Compute v2 .. autoprogram-cliff:: openstack.compute.v2 :command: server create +.. autoprogram-cliff:: openstack.compute.v2 + :command: server evacuate + .. autoprogram-cliff:: openstack.compute.v2 :command: server delete diff --git a/openstackclient/compute/v2/server.py b/openstackclient/compute/v2/server.py index 294b468331..7f1bc08852 100644 --- a/openstackclient/compute/v2/server.py +++ b/openstackclient/compute/v2/server.py @@ -2502,6 +2502,118 @@ class RebuildServer(command.ShowOne): return zip(*sorted(details.items())) +class EvacuateServer(command.ShowOne): + _description = _("""Evacuate a server to a different host. + +This command is used to recreate a server after the host it was on has failed. +It can only be used if the compute service that manages the server is down. +This command should only be used by an admin after they have confirmed that the +instance is not running on the failed host. + +If the server instance was created with an ephemeral root disk on non-shared +storage the server will be rebuilt using the original glance image preserving +the ports and any attached data volumes. + +If the server uses boot for volume or has its root disk on shared storage the +root disk will be preserved and reused for the evacuated instance on the new +host.""") + + def get_parser(self, prog_name): + parser = super(EvacuateServer, self).get_parser(prog_name) + parser.add_argument( + 'server', + metavar='', + help=_('Server (name or ID)'), + ) + + parser.add_argument( + '--wait', action='store_true', + help=_('Wait for evacuation to complete'), + ) + parser.add_argument( + '--host', metavar='', default=None, + help=_( + 'Set the preferred host on which to rebuild the evacuated ' + 'server. The host will be validated by the scheduler. ' + '(supported by --os-compute-api-version 2.29 or above)' + ), + ) + shared_storage_group = parser.add_mutually_exclusive_group() + shared_storage_group.add_argument( + '--password', metavar='', default=None, + help=_( + 'Set the password on the evacuated instance. This option is ' + 'mutually exclusive with the --shared-storage option' + ), + ) + shared_storage_group.add_argument( + '--shared-storage', action='store_true', dest='shared_storage', + help=_( + 'Indicate that the instance is on shared storage. ' + 'This will be auto-calculated with ' + '--os-compute-api-version 2.14 and greater and should not ' + 'be used with later microversions. This option is mutually ' + 'exclusive with the --password option' + ), + ) + return parser + + def take_action(self, parsed_args): + + def _show_progress(progress): + if progress: + self.app.stdout.write('\rProgress: %s' % progress) + self.app.stdout.flush() + + compute_client = self.app.client_manager.compute + image_client = self.app.client_manager.image + + if parsed_args.host: + if compute_client.api_version < api_versions.APIVersion('2.29'): + msg = _( + '--os-compute-api-version 2.29 or later is required ' + 'to specify a preferred host.' + ) + raise exceptions.CommandError(msg) + + if parsed_args.shared_storage: + if compute_client.api_version > api_versions.APIVersion('2.13'): + msg = _( + '--os-compute-api-version 2.13 or earlier is required ' + 'to specify shared-storage.' + ) + raise exceptions.CommandError(msg) + + kwargs = { + 'host': parsed_args.host, + 'password': parsed_args.password, + } + + if compute_client.api_version <= api_versions.APIVersion('2.13'): + kwargs['on_shared_storage'] = parsed_args.shared_storage + + server = utils.find_resource( + compute_client.servers, parsed_args.server) + + server = server.evacuate(**kwargs) + + if parsed_args.wait: + if utils.wait_for_status( + compute_client.servers.get, + server.id, + callback=_show_progress, + ): + self.app.stdout.write(_('Complete\n')) + else: + LOG.error(_('Error evacuating server: %s'), server.id) + self.app.stdout.write(_('Error evacuating server\n')) + raise SystemExit + + details = _prep_server_detail( + compute_client, image_client, server, refresh=False) + return zip(*sorted(details.items())) + + class RemoveFixedIP(command.Command): _description = _("Remove fixed IP address from server") diff --git a/openstackclient/tests/unit/compute/v2/test_server.py b/openstackclient/tests/unit/compute/v2/test_server.py index 380ef66b4e..20e3f70e95 100644 --- a/openstackclient/tests/unit/compute/v2/test_server.py +++ b/openstackclient/tests/unit/compute/v2/test_server.py @@ -4982,6 +4982,167 @@ class TestServerRebuild(TestServer): self.cmd, arglist, verifylist) +class TestEvacuateServer(TestServer): + + def setUp(self): + super(TestEvacuateServer, self).setUp() + # Return value for utils.find_resource for image + self.image = image_fakes.FakeImage.create_one_image() + self.images_mock.get.return_value = self.image + + # Fake the rebuilt new server. + attrs = { + 'image': { + 'id': self.image.id + }, + 'networks': {}, + 'adminPass': 'passw0rd', + } + new_server = compute_fakes.FakeServer.create_one_server(attrs=attrs) + + # Fake the server to be rebuilt. The IDs of them should be the same. + attrs['id'] = new_server.id + methods = { + 'evacuate': new_server, + } + self.server = compute_fakes.FakeServer.create_one_server( + attrs=attrs, + methods=methods + ) + + # Return value for utils.find_resource for server. + self.servers_mock.get.return_value = self.server + + self.cmd = server.EvacuateServer(self.app, None) + + def _test_evacuate(self, args, verify_args, evac_args): + parsed_args = self.check_parser(self.cmd, args, verify_args) + + # Get the command object to test + self.cmd.take_action(parsed_args) + + self.servers_mock.get.assert_called_with(self.server.id) + self.server.evacuate.assert_called_with(**evac_args) + + def test_evacuate(self): + args = [ + self.server.id, + ] + verify_args = [ + ('server', self.server.id), + ] + evac_args = { + 'host': None, 'on_shared_storage': False, 'password': None, + } + self._test_evacuate(args, verify_args, evac_args) + + def test_evacuate_with_password(self): + args = [ + self.server.id, + '--password', 'password', + ] + verify_args = [ + ('server', self.server.id), + ('password', 'password'), + ] + evac_args = { + 'host': None, 'on_shared_storage': False, 'password': 'password', + } + self._test_evacuate(args, verify_args, evac_args) + + def test_evacuate_with_host(self): + self.app.client_manager.compute.api_version = \ + api_versions.APIVersion('2.29') + + host = 'target-host' + args = [ + self.server.id, + '--host', 'target-host', + ] + verify_args = [ + ('server', self.server.id), + ('host', 'target-host'), + ] + evac_args = {'host': host, 'password': None} + + self._test_evacuate(args, verify_args, evac_args) + + def test_evacuate_with_host_pre_v229(self): + self.app.client_manager.compute.api_version = \ + api_versions.APIVersion('2.28') + + args = [ + self.server.id, + '--host', 'target-host', + ] + verify_args = [ + ('server', self.server.id), + ('host', 'target-host'), + ] + parsed_args = self.check_parser(self.cmd, args, verify_args) + + self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + + def test_evacuate_without_share_storage(self): + self.app.client_manager.compute.api_version = \ + api_versions.APIVersion('2.13') + + args = [ + self.server.id, + '--shared-storage' + ] + verify_args = [ + ('server', self.server.id), + ('shared_storage', True), + ] + evac_args = { + 'host': None, 'on_shared_storage': True, 'password': None, + } + self._test_evacuate(args, verify_args, evac_args) + + def test_evacuate_without_share_storage_post_v213(self): + self.app.client_manager.compute.api_version = \ + api_versions.APIVersion('2.14') + + args = [ + self.server.id, + '--shared-storage' + ] + verify_args = [ + ('server', self.server.id), + ('shared_storage', True), + ] + parsed_args = self.check_parser(self.cmd, args, verify_args) + + self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + + @mock.patch.object(common_utils, 'wait_for_status', return_value=True) + def test_evacuate_with_wait_ok(self, mock_wait_for_status): + args = [ + self.server.id, + '--wait', + ] + verify_args = [ + ('server', self.server.id), + ('wait', True), + ] + evac_args = { + 'host': None, 'on_shared_storage': False, 'password': None, + } + self._test_evacuate(args, verify_args, evac_args) + mock_wait_for_status.assert_called_once_with( + self.servers_mock.get, + self.server.id, + callback=mock.ANY, + ) + + class TestServerRemoveFixedIP(TestServer): def setUp(self): diff --git a/releasenotes/notes/add-server-evacuate-8359246692cb642f.yaml b/releasenotes/notes/add-server-evacuate-8359246692cb642f.yaml new file mode 100644 index 0000000000..41bbed736a --- /dev/null +++ b/releasenotes/notes/add-server-evacuate-8359246692cb642f.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add ``server evacuate`` command. This command will recreate an instance + from scratch on a new host and is intended to be used when the original + host fails. diff --git a/setup.cfg b/setup.cfg index 8363ec6cfd..a29852e353 100644 --- a/setup.cfg +++ b/setup.cfg @@ -103,6 +103,7 @@ openstack.compute.v2 = server_create = openstackclient.compute.v2.server:CreateServer server_delete = openstackclient.compute.v2.server:DeleteServer server_dump_create = openstackclient.compute.v2.server:CreateServerDump + server_evacuate = openstackclient.compute.v2.server:EvacuateServer server_list = openstackclient.compute.v2.server:ListServer server_lock = openstackclient.compute.v2.server:LockServer server_migrate = openstackclient.compute.v2.server:MigrateServer