diff --git a/doc/source/command-objects/server-backup.rst b/doc/source/command-objects/server-backup.rst new file mode 100644 index 0000000000..23e17d5f5d --- /dev/null +++ b/doc/source/command-objects/server-backup.rst @@ -0,0 +1,44 @@ +============= +server backup +============= + +A server backup is a disk image created in the Image store from a running server +instance. The backup command manages the number of archival copies to retain. + +Compute v2 + +server backup create +-------------------- + +Create a server backup image + +.. program:: server create +.. code:: bash + + os server backup create + [--name ] + [--type ] + [--rotate ] + [--wait] + + +.. option:: --name + + Name of the backup image (default: server name) + +.. option:: --type + + Used to populate the ``backup_type`` property of the backup + image (default: empty) + +.. option:: --rotate + + Number of backup images to keep (default: 1) + +.. option:: --wait + + Wait for operation to complete + +.. describe:: + + Server to back up (name or ID) diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 12542d1c73..a6d9404790 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -118,6 +118,7 @@ referring to both Compute and Volume quotas. * ``security group``: (**Compute**, **Network**) - groups of network access rules * ``security group rule``: (**Compute**, **Network**) - the individual rules that define protocol/IP/port access * ``server``: (**Compute**) virtual machine instance +* ``server backup``: (**Compute**) backup server disk image by using snapshot method * ``server dump``: (**Compute**) a dump file of a server created by features like kdump * ``server group``: (**Compute**) a grouping of servers * ``server image``: (**Compute**) saved server disk image diff --git a/openstackclient/compute/v2/server_backup.py b/openstackclient/compute/v2/server_backup.py new file mode 100644 index 0000000000..24d7101521 --- /dev/null +++ b/openstackclient/compute/v2/server_backup.py @@ -0,0 +1,134 @@ +# Copyright 2012-2013 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +"""Compute v2 Server action implementations""" + +import sys + +from oslo_utils import importutils +import six + +from openstackclient.common import command +from openstackclient.common import exceptions +from openstackclient.common import utils +from openstackclient.i18n import _ + + +def _show_progress(progress): + if progress: + sys.stderr.write('\rProgress: %s' % progress) + sys.stderr.flush() + + +class CreateServerBackup(command.ShowOne): + """Create a server backup image""" + + IMAGE_API_VERSIONS = { + "1": "openstackclient.image.v1.image", + "2": "openstackclient.image.v2.image", + } + + def get_parser(self, prog_name): + parser = super(CreateServerBackup, self).get_parser(prog_name) + parser.add_argument( + 'server', + metavar='', + help=_('Server to back up (name or ID)'), + ) + parser.add_argument( + '--name', + metavar='', + help=_('Name of the backup image (default: server name)'), + ) + parser.add_argument( + '--type', + metavar='', + help=_( + 'Used to populate the backup_type property of the backup ' + 'image (default: empty)' + ), + ) + parser.add_argument( + '--rotate', + metavar='', + type=int, + help=_('Number of backups to keep (default: 1)'), + ) + parser.add_argument( + '--wait', + action='store_true', + help=_('Wait for backup image create to complete'), + ) + return parser + + def take_action(self, parsed_args): + compute_client = self.app.client_manager.compute + + server = utils.find_resource( + compute_client.servers, + parsed_args.server, + ) + + # Set sane defaults as this API wants all mouths to be fed + if parsed_args.name is None: + backup_name = server.name + else: + backup_name = parsed_args.name + if parsed_args.type is None: + backup_type = "" + else: + backup_type = parsed_args.type + if parsed_args.rotate is None: + backup_rotation = 1 + else: + backup_rotation = parsed_args.rotate + + compute_client.servers.backup( + server.id, + backup_name, + backup_type, + backup_rotation, + ) + + image_client = self.app.client_manager.image + image = utils.find_resource( + image_client.images, + backup_name, + ) + + if parsed_args.wait: + if utils.wait_for_status( + image_client.images.get, + image.id, + callback=_show_progress, + ): + sys.stdout.write('\n') + else: + msg = _('Error creating server backup: %s') % parsed_args.name + raise exceptions.CommandError(msg) + + if self.app.client_manager._api_version['image'] == '1': + info = {} + info.update(image._info) + info['properties'] = utils.format_dict(info.get('properties', {})) + else: + # Get the right image module to format the output + image_module = importutils.import_module( + self.IMAGE_API_VERSIONS[ + self.app.client_manager._api_version['image'] + ] + ) + info = image_module._format_image(image) + return zip(*sorted(six.iteritems(info))) diff --git a/openstackclient/tests/compute/v2/test_server_backup.py b/openstackclient/tests/compute/v2/test_server_backup.py new file mode 100644 index 0000000000..b35f9f52a8 --- /dev/null +++ b/openstackclient/tests/compute/v2/test_server_backup.py @@ -0,0 +1,270 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +import mock + +from openstackclient.common import exceptions +from openstackclient.common import utils as common_utils +from openstackclient.compute.v2 import server_backup +from openstackclient.tests.compute.v2 import fakes as compute_fakes +from openstackclient.tests.image.v2 import fakes as image_fakes + + +class TestServerBackup(compute_fakes.TestComputev2): + + def setUp(self): + super(TestServerBackup, self).setUp() + + # Get a shortcut to the compute client ServerManager Mock + self.servers_mock = self.app.client_manager.compute.servers + self.servers_mock.reset_mock() + + # Get a shortcut to the image client ImageManager Mock + self.images_mock = self.app.client_manager.image.images + self.images_mock.reset_mock() + + # Set object attributes to be tested. Could be overwriten in subclass. + self.attrs = {} + + # Set object methods to be tested. Could be overwriten in subclass. + self.methods = {} + + def setup_servers_mock(self, count): + servers = compute_fakes.FakeServer.create_servers( + attrs=self.attrs, + methods=self.methods, + count=count, + ) + + # This is the return value for utils.find_resource() + self.servers_mock.get = compute_fakes.FakeServer.get_servers( + servers, + 0, + ) + return servers + + +class TestServerBackupCreate(TestServerBackup): + + # Just return whatever Image is testing with these days + def image_columns(self, image): + columnlist = tuple(sorted(image.keys())) + return columnlist + + def image_data(self, image): + datalist = ( + image['id'], + image['name'], + image['owner'], + image['protected'], + 'active', + common_utils.format_list(image.get('tags')), + image['visibility'], + ) + return datalist + + def setUp(self): + super(TestServerBackupCreate, self).setUp() + + # Get the command object to test + self.cmd = server_backup.CreateServerBackup(self.app, None) + + self.methods = { + 'backup': None, + } + + def setup_images_mock(self, count, servers=None): + if servers: + images = image_fakes.FakeImage.create_images( + attrs={ + 'name': servers[0].name, + 'status': 'active', + }, + count=count, + ) + else: + images = image_fakes.FakeImage.create_images( + attrs={ + 'status': 'active', + }, + count=count, + ) + + self.images_mock.get = mock.MagicMock(side_effect=images) + return images + + def test_server_backup_defaults(self): + servers = self.setup_servers_mock(count=1) + images = self.setup_images_mock(count=1, servers=servers) + + arglist = [ + servers[0].id, + ] + verifylist = [ + ('name', None), + ('type', None), + ('rotate', None), + ('wait', False), + ('server', servers[0].id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + # ServerManager.backup(server, backup_name, backup_type, rotation) + self.servers_mock.backup.assert_called_with( + servers[0].id, + servers[0].name, + '', + 1, + ) + + self.assertEqual(self.image_columns(images[0]), columns) + self.assertEqual(self.image_data(images[0]), data) + + def test_server_backup_create_options(self): + servers = self.setup_servers_mock(count=1) + images = self.setup_images_mock(count=1, servers=servers) + + arglist = [ + '--name', 'image', + '--type', 'daily', + '--rotate', '2', + servers[0].id, + ] + verifylist = [ + ('name', 'image'), + ('type', 'daily'), + ('rotate', 2), + ('server', servers[0].id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + # ServerManager.backup(server, backup_name, backup_type, rotation) + self.servers_mock.backup.assert_called_with( + servers[0].id, + 'image', + 'daily', + 2, + ) + + self.assertEqual(self.image_columns(images[0]), columns) + self.assertEqual(self.image_data(images[0]), data) + + @mock.patch.object(common_utils, 'wait_for_status', return_value=False) + def test_server_backup_wait_fail(self, mock_wait_for_status): + servers = self.setup_servers_mock(count=1) + images = image_fakes.FakeImage.create_images( + attrs={ + 'name': servers[0].name, + 'status': 'active', + }, + count=5, + ) + + self.images_mock.get = mock.MagicMock( + side_effect=images, + ) + + arglist = [ + '--name', 'image', + '--type', 'daily', + '--wait', + servers[0].id, + ] + verifylist = [ + ('name', 'image'), + ('type', 'daily'), + ('wait', True), + ('server', servers[0].id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args, + ) + + # ServerManager.backup(server, backup_name, backup_type, rotation) + self.servers_mock.backup.assert_called_with( + servers[0].id, + 'image', + 'daily', + 1, + ) + + mock_wait_for_status.assert_called_once_with( + self.images_mock.get, + images[0].id, + callback=mock.ANY + ) + + @mock.patch.object(common_utils, 'wait_for_status', return_value=True) + def test_server_backup_wait_ok(self, mock_wait_for_status): + servers = self.setup_servers_mock(count=1) + images = image_fakes.FakeImage.create_images( + attrs={ + 'name': servers[0].name, + 'status': 'active', + }, + count=5, + ) + + self.images_mock.get = mock.MagicMock( + side_effect=images, + ) + + arglist = [ + '--name', 'image', + '--type', 'daily', + '--wait', + servers[0].id, + ] + verifylist = [ + ('name', 'image'), + ('type', 'daily'), + ('wait', True), + ('server', servers[0].id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # In base command class ShowOne in cliff, abstract method take_action() + # returns a two-part tuple with a tuple of column names and a tuple of + # data to be shown. + columns, data = self.cmd.take_action(parsed_args) + + # ServerManager.backup(server, backup_name, backup_type, rotation) + self.servers_mock.backup.assert_called_with( + servers[0].id, + 'image', + 'daily', + 1, + ) + + mock_wait_for_status.assert_called_once_with( + self.images_mock.get, + images[0].id, + callback=mock.ANY + ) + + self.assertEqual(self.image_columns(images[0]), columns) + self.assertEqual(self.image_data(images[0]), data) diff --git a/openstackclient/tests/fakes.py b/openstackclient/tests/fakes.py index 229b46529c..fb7a957af4 100644 --- a/openstackclient/tests/fakes.py +++ b/openstackclient/tests/fakes.py @@ -105,6 +105,9 @@ class FakeClient(object): class FakeClientManager(object): + _api_version = { + 'image': '2', + } def __init__(self): self.compute = None diff --git a/releasenotes/notes/add-server-backup-e63feaebb6140f83.yaml b/releasenotes/notes/add-server-backup-e63feaebb6140f83.yaml new file mode 100644 index 0000000000..f8aa4291fb --- /dev/null +++ b/releasenotes/notes/add-server-backup-e63feaebb6140f83.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add support for the ``server backup create`` command diff --git a/setup.cfg b/setup.cfg index a62f5d25cd..6a2ce01db7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -131,6 +131,8 @@ openstack.compute.v2 = server_unset = openstackclient.compute.v2.server:UnsetServer server_unshelve = openstackclient.compute.v2.server:UnshelveServer + server_backup_create = openstackclient.compute.v2.server_backup:CreateServerBackup + server_group_create = openstackclient.compute.v2.server_group:CreateServerGroup server_group_delete = openstackclient.compute.v2.server_group:DeleteServerGroup server_group_list = openstackclient.compute.v2.server_group:ListServerGroup