diff --git a/openstackclient/compute/v2/server.py b/openstackclient/compute/v2/server.py index 7edbb18e83..1e4f19361f 100644 --- a/openstackclient/compute/v2/server.py +++ b/openstackclient/compute/v2/server.py @@ -772,6 +772,19 @@ class CreateServer(command.ShowOne): 'volume.' ), ) + disk_group.add_argument( + '--snapshot', + metavar='', + help=_( + 'Create server using this snapshot as the boot disk (name or ' + 'ID)\n' + 'This option automatically creates a block device mapping ' + 'with a boot index of 0. On many hypervisors (libvirt/kvm ' + 'for example) this will be device vda. Do not create a ' + 'duplicate mapping using --block-device-mapping for this ' + 'volume.' + ), + ) parser.add_argument( '--boot-from-volume', metavar='', @@ -784,7 +797,8 @@ class CreateServer(command.ShowOne): 'given size (in GB) from the specified image and use it ' 'as the root disk of the server. The root volume will not ' 'be deleted when the server is deleted. This option is ' - 'mutually exclusive with the ``--volume`` option.' + 'mutually exclusive with the ``--volume`` and ``--snapshot`` ' + 'options.' ) ) parser.add_argument( @@ -810,6 +824,28 @@ class CreateServer(command.ShowOne): '(optional)\n' ), ) + parser.add_argument( + '--swap', + metavar='', + type=int, + help=( + "Create and attach a local swap block device of " + "MiB." + ), + ) + parser.add_argument( + '--ephemeral', + metavar='', + action=parseractions.MultiKeyValueAction, + dest='ephemerals', + default=[], + required_keys=['size'], + optional_keys=['format'], + help=( + "Create and attach a local ephemeral block device of " + "GiB and format it to ." + ), + ) parser.add_argument( '--network', metavar="", @@ -929,12 +965,14 @@ class CreateServer(command.ShowOne): parser.add_argument( '--availability-zone', metavar='', - help=_('Select an availability zone for the server. ' - 'Host and node are optional parameters. ' - 'Availability zone in the format ' - '::, ' - '::, : ' - 'or '), + help=_( + 'Select an availability zone for the server. ' + 'Host and node are optional parameters. ' + 'Availability zone in the format ' + '::, ' + '::, : ' + 'or ' + ), ) parser.add_argument( '--host', @@ -1000,11 +1038,6 @@ class CreateServer(command.ShowOne): default=1, help=_('Maximum number of servers to launch (default=1)'), ) - parser.add_argument( - '--wait', - action='store_true', - help=_('Wait for build to complete'), - ) parser.add_argument( '--tag', metavar='', @@ -1017,6 +1050,11 @@ class CreateServer(command.ShowOne): '(supported by --os-compute-api-version 2.52 or above)' ), ) + parser.add_argument( + '--wait', + action='store_true', + help=_('Wait for build to complete'), + ) return parser def take_action(self, parsed_args): @@ -1092,7 +1130,6 @@ class CreateServer(command.ShowOne): ) raise exceptions.CommandError(msg) - # Lookup parsed_args.volume volume = None if parsed_args.volume: # --volume and --boot-from-volume are mutually exclusive. @@ -1105,7 +1142,18 @@ class CreateServer(command.ShowOne): parsed_args.volume, ).id - # Lookup parsed_args.flavor + snapshot = None + if parsed_args.snapshot: + # --snapshot and --boot-from-volume are mutually exclusive. + if parsed_args.boot_from_volume: + msg = _('--snapshot is not allowed with --boot-from-volume') + raise exceptions.CommandError(msg) + + snapshot = utils.find_resource( + volume_client.volume_snapshots, + parsed_args.snapshot, + ).id + flavor = utils.find_resource( compute_client.flavors, parsed_args.flavor) @@ -1156,6 +1204,14 @@ class CreateServer(command.ShowOne): 'source_type': 'volume', 'destination_type': 'volume' }] + elif snapshot: + block_device_mapping_v2 = [{ + 'uuid': snapshot, + 'boot_index': '0', + 'source_type': 'snapshot', + 'destination_type': 'volume', + 'delete_on_termination': False + }] elif parsed_args.boot_from_volume: # Tell nova to create a root volume from the image provided. block_device_mapping_v2 = [{ @@ -1168,6 +1224,30 @@ class CreateServer(command.ShowOne): # If booting from volume we do not pass an image to compute. image = None + if parsed_args.swap: + block_device_mapping_v2.append({ + 'boot_index': -1, + 'source_type': 'blank', + 'destination_type': 'local', + 'guest_format': 'swap', + 'volume_size': parsed_args.swap, + 'delete_on_termination': True, + }) + + for mapping in parsed_args.ephemerals: + block_device_mapping_dict = { + 'boot_index': -1, + 'source_type': 'blank', + 'destination_type': 'local', + 'delete_on_termination': True, + 'volume_size': mapping['size'], + } + + if 'format' in mapping: + block_device_mapping_dict['guest_format'] = mapping['format'] + + block_device_mapping_v2.append(block_device_mapping_dict) + # Handle block device by device name order, like: vdb -> vdc -> vdd for mapping in parsed_args.block_device_mapping: if mapping['source_type'] == 'volume': diff --git a/openstackclient/tests/unit/compute/v2/test_server.py b/openstackclient/tests/unit/compute/v2/test_server.py index 58daf531ec..ce93f21ea3 100644 --- a/openstackclient/tests/unit/compute/v2/test_server.py +++ b/openstackclient/tests/unit/compute/v2/test_server.py @@ -1925,6 +1925,109 @@ class TestServerCreate(TestServer): self.assertEqual(self.columns, columns) self.assertEqual(self.datalist(), data) + def test_server_create_with_volume(self): + arglist = [ + '--flavor', self.flavor.id, + '--volume', self.volume.name, + self.new_server.name, + ] + verifylist = [ + ('flavor', self.flavor.id), + ('volume', self.volume.name), + ('server_name', self.new_server.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # CreateServer.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'meta': None, + 'files': {}, + 'reservation_id': None, + 'min_count': 1, + 'max_count': 1, + 'security_groups': [], + 'userdata': None, + 'key_name': None, + 'availability_zone': None, + 'admin_pass': None, + 'block_device_mapping_v2': [{ + 'uuid': self.volume.id, + 'boot_index': '0', + 'source_type': 'volume', + 'destination_type': 'volume', + }], + 'nics': [], + 'scheduler_hints': {}, + 'config_drive': None, + } + # ServerManager.create(name, image, flavor, **kwargs) + self.servers_mock.create.assert_called_with( + self.new_server.name, + None, + self.flavor, + **kwargs + ) + self.volumes_mock.get.assert_called_once_with( + self.volume.name) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist(), data) + + def test_server_create_with_snapshot(self): + arglist = [ + '--flavor', self.flavor.id, + '--snapshot', self.snapshot.name, + self.new_server.name, + ] + verifylist = [ + ('flavor', self.flavor.id), + ('snapshot', self.snapshot.name), + ('server_name', self.new_server.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # CreateServer.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'meta': None, + 'files': {}, + 'reservation_id': None, + 'min_count': 1, + 'max_count': 1, + 'security_groups': [], + 'userdata': None, + 'key_name': None, + 'availability_zone': None, + 'admin_pass': None, + 'block_device_mapping_v2': [{ + 'uuid': self.snapshot.id, + 'boot_index': '0', + 'source_type': 'snapshot', + 'destination_type': 'volume', + 'delete_on_termination': False, + }], + 'nics': [], + 'scheduler_hints': {}, + 'config_drive': None, + } + # ServerManager.create(name, image, flavor, **kwargs) + self.servers_mock.create.assert_called_with( + self.new_server.name, + None, + self.flavor, + **kwargs + ) + self.snapshots_mock.get.assert_called_once_with( + self.snapshot.name) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist(), data) + def test_server_create_with_block_device_mapping(self): arglist = [ '--image', 'image1', @@ -2575,6 +2678,136 @@ class TestServerCreate(TestServer): self.assertEqual(self.columns, columns) self.assertEqual(self.datalist(), data) + def test_server_create_with_swap(self): + arglist = [ + '--image', 'image1', + '--flavor', self.flavor.id, + '--swap', '1024', + self.new_server.name, + ] + verifylist = [ + ('image', 'image1'), + ('flavor', self.flavor.id), + ('swap', 1024), + ('server_name', self.new_server.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # CreateServer.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'meta': None, + 'files': {}, + 'reservation_id': None, + 'min_count': 1, + 'max_count': 1, + 'security_groups': [], + 'userdata': None, + 'key_name': None, + 'availability_zone': None, + 'admin_pass': None, + 'block_device_mapping_v2': [{ + 'boot_index': -1, + 'source_type': 'blank', + 'destination_type': 'local', + 'guest_format': 'swap', + 'volume_size': 1024, + 'delete_on_termination': True, + }], + 'nics': [], + 'scheduler_hints': {}, + 'config_drive': None, + } + # ServerManager.create(name, image, flavor, **kwargs) + self.servers_mock.create.assert_called_with( + self.new_server.name, + self.image, + self.flavor, + **kwargs + ) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist(), data) + + def test_server_create_with_ephemeral(self): + arglist = [ + '--image', 'image1', + '--flavor', self.flavor.id, + '--ephemeral', 'size=1024,format=ext4', + self.new_server.name, + ] + verifylist = [ + ('image', 'image1'), + ('flavor', self.flavor.id), + ('ephemerals', [{'size': '1024', 'format': 'ext4'}]), + ('server_name', self.new_server.name), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # CreateServer.take_action() returns two tuples + columns, data = self.cmd.take_action(parsed_args) + + # Set expected values + kwargs = { + 'meta': None, + 'files': {}, + 'reservation_id': None, + 'min_count': 1, + 'max_count': 1, + 'security_groups': [], + 'userdata': None, + 'key_name': None, + 'availability_zone': None, + 'admin_pass': None, + 'block_device_mapping_v2': [{ + 'boot_index': -1, + 'source_type': 'blank', + 'destination_type': 'local', + 'guest_format': 'ext4', + 'volume_size': '1024', + 'delete_on_termination': True, + }], + 'nics': [], + 'scheduler_hints': {}, + 'config_drive': None, + } + # ServerManager.create(name, image, flavor, **kwargs) + self.servers_mock.create.assert_called_with( + self.new_server.name, + self.image, + self.flavor, + **kwargs + ) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.datalist(), data) + + def test_server_create_with_ephemeral_missing_key(self): + arglist = [ + '--image', 'image1', + '--flavor', self.flavor.id, + '--ephemeral', 'format=ext3', + self.new_server.name, + ] + self.assertRaises( + argparse.ArgumentTypeError, + self.check_parser, + self.cmd, arglist, []) + + def test_server_create_with_ephemeral_invalid_key(self): + arglist = [ + '--image', 'image1', + '--flavor', self.flavor.id, + '--ephemeral', 'size=1024,foo=bar', + self.new_server.name, + ] + self.assertRaises( + argparse.ArgumentTypeError, + self.check_parser, + self.cmd, arglist, []) + def test_server_create_invalid_hint(self): # Not a key-value pair arglist = [ diff --git a/releasenotes/notes/add-missing-server-create-opts-d5e32bd743e9e132.yaml b/releasenotes/notes/add-missing-server-create-opts-d5e32bd743e9e132.yaml new file mode 100644 index 0000000000..b82a9d30d1 --- /dev/null +++ b/releasenotes/notes/add-missing-server-create-opts-d5e32bd743e9e132.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add a number of additional options to the ``server create`` command: + + - ``--snapshot`` + - ``--ephemeral`` + - ``--swap``