Merge "Add image create support for image v2"
This commit is contained in:
commit
b2e72e6aee
@ -7,7 +7,7 @@ Image v1, v2
|
||||
image create
|
||||
------------
|
||||
|
||||
*Only supported for Image v1*
|
||||
*Image v1, v2*
|
||||
|
||||
Create/upload an image
|
||||
|
||||
@ -32,6 +32,7 @@ Create/upload an image
|
||||
[--protected | --unprotected]
|
||||
[--public | --private]
|
||||
[--property <key=value> [...] ]
|
||||
[--tag <tag> [...] ]
|
||||
<image-name>
|
||||
|
||||
.. option:: --id <id>
|
||||
@ -42,6 +43,8 @@ Create/upload an image
|
||||
|
||||
Upload image to this store
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --container-format <container-format>
|
||||
|
||||
Image container format (default: bare)
|
||||
@ -54,10 +57,14 @@ Create/upload an image
|
||||
|
||||
Image owner project name or ID
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --size <size>
|
||||
|
||||
Image size, in bytes (only used with --location and --copy-from)
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --min-disk <disk-gb>
|
||||
|
||||
Minimum disk size needed to boot image, in gigabytes
|
||||
@ -70,10 +77,14 @@ Create/upload an image
|
||||
|
||||
Download image from an existing URL
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --copy-from <image-url>
|
||||
|
||||
Copy image from the data store (similar to --location)
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --file <file>
|
||||
|
||||
Upload image from local file
|
||||
@ -90,6 +101,8 @@ Create/upload an image
|
||||
|
||||
Image hash used for verification
|
||||
|
||||
*Image version 1 only.*
|
||||
|
||||
.. option:: --protected
|
||||
|
||||
Prevent image from being deleted
|
||||
@ -110,6 +123,12 @@ Create/upload an image
|
||||
|
||||
Set a property on this image (repeat for multiple values)
|
||||
|
||||
.. option:: --tag <tag>
|
||||
|
||||
Set a tag on this image (repeat for multiple values)
|
||||
|
||||
.. versionadded:: 2
|
||||
|
||||
.. describe:: <image-name>
|
||||
|
||||
New image name
|
||||
|
@ -22,14 +22,19 @@ import six
|
||||
from cliff import command
|
||||
from cliff import lister
|
||||
from cliff import show
|
||||
|
||||
from glanceclient.common import utils as gc_utils
|
||||
|
||||
from openstackclient.api import utils as api_utils
|
||||
from openstackclient.common import exceptions
|
||||
from openstackclient.common import parseractions
|
||||
from openstackclient.common import utils
|
||||
from openstackclient.identity import common
|
||||
|
||||
|
||||
DEFAULT_CONTAINER_FORMAT = 'bare'
|
||||
DEFAULT_DISK_FORMAT = 'raw'
|
||||
|
||||
|
||||
class AddProjectToImage(show.ShowOne):
|
||||
"""Associate project with image"""
|
||||
|
||||
@ -72,6 +77,186 @@ class AddProjectToImage(show.ShowOne):
|
||||
return zip(*sorted(six.iteritems(image_member._info)))
|
||||
|
||||
|
||||
class CreateImage(show.ShowOne):
|
||||
"""Create/upload an image"""
|
||||
|
||||
log = logging.getLogger(__name__ + ".CreateImage")
|
||||
deadopts = ('owner', 'size', 'location', 'copy-from', 'checksum', 'store')
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super(CreateImage, self).get_parser(prog_name)
|
||||
# TODO(mordred): add --volume and --force parameters and support
|
||||
# TODO(bunting): There are additional arguments that v1 supported
|
||||
# that v2 either doesn't support or supports weirdly.
|
||||
# --checksum - could be faked clientside perhaps?
|
||||
# --owner - could be set as an update after the put?
|
||||
# --location - maybe location add?
|
||||
# --size - passing image size is actually broken in python-glanceclient
|
||||
# --copy-from - does not exist in v2
|
||||
# --store - does not exits in v2
|
||||
parser.add_argument(
|
||||
"name",
|
||||
metavar="<image-name>",
|
||||
help="New image name",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id",
|
||||
metavar="<id>",
|
||||
help="Image ID to reserve",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--container-format",
|
||||
default=DEFAULT_CONTAINER_FORMAT,
|
||||
metavar="<container-format>",
|
||||
help="Image container format "
|
||||
"(default: %s)" % DEFAULT_CONTAINER_FORMAT,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--disk-format",
|
||||
default=DEFAULT_DISK_FORMAT,
|
||||
metavar="<disk-format>",
|
||||
help="Image disk format "
|
||||
"(default: %s)" % DEFAULT_DISK_FORMAT,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-disk",
|
||||
metavar="<disk-gb>",
|
||||
type=int,
|
||||
help="Minimum disk size needed to boot image, in gigabytes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-ram",
|
||||
metavar="<ram-mb>",
|
||||
type=int,
|
||||
help="Minimum RAM size needed to boot image, in megabytes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
metavar="<file>",
|
||||
help="Upload image from local file",
|
||||
)
|
||||
protected_group = parser.add_mutually_exclusive_group()
|
||||
protected_group.add_argument(
|
||||
"--protected",
|
||||
action="store_true",
|
||||
help="Prevent image from being deleted",
|
||||
)
|
||||
protected_group.add_argument(
|
||||
"--unprotected",
|
||||
action="store_true",
|
||||
help="Allow image to be deleted (default)",
|
||||
)
|
||||
public_group = parser.add_mutually_exclusive_group()
|
||||
public_group.add_argument(
|
||||
"--public",
|
||||
action="store_true",
|
||||
help="Image is accessible to the public",
|
||||
)
|
||||
public_group.add_argument(
|
||||
"--private",
|
||||
action="store_true",
|
||||
help="Image is inaccessible to the public (default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--property",
|
||||
dest="properties",
|
||||
metavar="<key=value>",
|
||||
action=parseractions.KeyValueAction,
|
||||
help="Set a property on this image "
|
||||
"(repeat option to set multiple properties)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
dest="tags",
|
||||
metavar="<tag>",
|
||||
action='append',
|
||||
help="Set a tag on this image "
|
||||
"(repeat option to set multiple tags)",
|
||||
)
|
||||
for deadopt in self.deadopts:
|
||||
parser.add_argument(
|
||||
"--%s" % deadopt,
|
||||
metavar="<%s>" % deadopt,
|
||||
dest=deadopt.replace('-', '_'),
|
||||
help=argparse.SUPPRESS
|
||||
)
|
||||
return parser
|
||||
|
||||
def take_action(self, parsed_args):
|
||||
self.log.debug("take_action(%s)", parsed_args)
|
||||
image_client = self.app.client_manager.image
|
||||
|
||||
for deadopt in self.deadopts:
|
||||
if getattr(parsed_args, deadopt.replace('-', '_'), None):
|
||||
raise exceptions.CommandError(
|
||||
"ERROR: --%s was given, which is an Image v1 option"
|
||||
" that is no longer supported in Image v2" % deadopt)
|
||||
|
||||
# Build an attribute dict from the parsed args, only include
|
||||
# attributes that were actually set on the command line
|
||||
kwargs = {}
|
||||
copy_attrs = ('name', 'id',
|
||||
'container_format', 'disk_format',
|
||||
'min_disk', 'min_ram',
|
||||
'tags')
|
||||
for attr in copy_attrs:
|
||||
if attr in parsed_args:
|
||||
val = getattr(parsed_args, attr, None)
|
||||
if val:
|
||||
# Only include a value in kwargs for attributes that
|
||||
# are actually present on the command line
|
||||
kwargs[attr] = val
|
||||
# properties should get flattened into the general kwargs
|
||||
if getattr(parsed_args, 'properties', None):
|
||||
for k, v in six.iteritems(parsed_args.properties):
|
||||
kwargs[k] = str(v)
|
||||
# Handle exclusive booleans with care
|
||||
# Avoid including attributes in kwargs if an option is not
|
||||
# present on the command line. These exclusive booleans are not
|
||||
# a single value for the pair of options because the default must be
|
||||
# to do nothing when no options are present as opposed to always
|
||||
# setting a default.
|
||||
if parsed_args.protected:
|
||||
kwargs['protected'] = True
|
||||
if parsed_args.unprotected:
|
||||
kwargs['protected'] = False
|
||||
if parsed_args.public:
|
||||
kwargs['visibility'] = 'public'
|
||||
if parsed_args.private:
|
||||
kwargs['visibility'] = 'private'
|
||||
|
||||
# open the file first to ensure any failures are handled before the
|
||||
# image is created
|
||||
fp = gc_utils.get_data_file(parsed_args)
|
||||
|
||||
if fp is None and parsed_args.file:
|
||||
self.log.warning("Failed to get an image file.")
|
||||
return {}, {}
|
||||
|
||||
image = image_client.images.create(**kwargs)
|
||||
|
||||
if fp is not None:
|
||||
with fp:
|
||||
try:
|
||||
image_client.images.upload(image.id, fp)
|
||||
except Exception as e:
|
||||
# If the upload fails for some reason attempt to remove the
|
||||
# dangling queued image made by the create() call above but
|
||||
# only if the user did not specify an id which indicates
|
||||
# the Image already exists and should be left alone.
|
||||
try:
|
||||
if 'id' not in kwargs:
|
||||
image_client.images.delete(image.id)
|
||||
except Exception:
|
||||
pass # we don't care about this one
|
||||
raise e # now, throw the upload exception again
|
||||
|
||||
# update the image after the data has been uploaded
|
||||
image = image_client.images.get(image.id)
|
||||
|
||||
return zip(*sorted(six.iteritems(image)))
|
||||
|
||||
|
||||
class DeleteImage(command.Command):
|
||||
"""Delete image(s)"""
|
||||
|
||||
|
@ -19,6 +19,7 @@ import mock
|
||||
import warlock
|
||||
|
||||
from glanceclient.v2 import schemas
|
||||
from openstackclient.common import exceptions
|
||||
from openstackclient.image.v2 import image
|
||||
from openstackclient.tests import fakes
|
||||
from openstackclient.tests.identity.v3 import fakes as identity_fakes
|
||||
@ -41,6 +42,191 @@ class TestImage(image_fakes.TestImagev2):
|
||||
self.domain_mock.reset_mock()
|
||||
|
||||
|
||||
class TestImageCreate(TestImage):
|
||||
|
||||
def setUp(self):
|
||||
super(TestImageCreate, self).setUp()
|
||||
|
||||
self.images_mock.create.return_value = fakes.FakeResource(
|
||||
None,
|
||||
copy.deepcopy(image_fakes.IMAGE),
|
||||
loaded=True,
|
||||
)
|
||||
# This is the return value for utils.find_resource()
|
||||
self.images_mock.get.return_value = copy.deepcopy(image_fakes.IMAGE)
|
||||
self.images_mock.update.return_value = fakes.FakeResource(
|
||||
None,
|
||||
copy.deepcopy(image_fakes.IMAGE),
|
||||
loaded=True,
|
||||
)
|
||||
|
||||
# Get the command object to test
|
||||
self.cmd = image.CreateImage(self.app, None)
|
||||
|
||||
def test_image_reserve_no_options(self):
|
||||
mock_exception = {
|
||||
'find.side_effect': exceptions.CommandError('x'),
|
||||
}
|
||||
self.images_mock.configure_mock(**mock_exception)
|
||||
arglist = [
|
||||
image_fakes.image_name,
|
||||
]
|
||||
verifylist = [
|
||||
('container_format', image.DEFAULT_CONTAINER_FORMAT),
|
||||
('disk_format', image.DEFAULT_DISK_FORMAT),
|
||||
('name', image_fakes.image_name),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
# DisplayCommandBase.take_action() returns two tuples
|
||||
columns, data = self.cmd.take_action(parsed_args)
|
||||
|
||||
# ImageManager.create(name=, **)
|
||||
self.images_mock.create.assert_called_with(
|
||||
name=image_fakes.image_name,
|
||||
container_format=image.DEFAULT_CONTAINER_FORMAT,
|
||||
disk_format=image.DEFAULT_DISK_FORMAT,
|
||||
)
|
||||
|
||||
# Verify update() was not called, if it was show the args
|
||||
self.assertEqual(self.images_mock.update.call_args_list, [])
|
||||
|
||||
self.images_mock.upload.assert_called_with(
|
||||
mock.ANY, mock.ANY,
|
||||
)
|
||||
|
||||
self.assertEqual(image_fakes.IMAGE_columns, columns)
|
||||
self.assertEqual(image_fakes.IMAGE_data, data)
|
||||
|
||||
@mock.patch('glanceclient.common.utils.get_data_file', name='Open')
|
||||
def test_image_reserve_options(self, mock_open):
|
||||
mock_file = mock.MagicMock(name='File')
|
||||
mock_open.return_value = mock_file
|
||||
mock_open.read.return_value = None
|
||||
mock_exception = {
|
||||
'find.side_effect': exceptions.CommandError('x'),
|
||||
}
|
||||
self.images_mock.configure_mock(**mock_exception)
|
||||
arglist = [
|
||||
'--container-format', 'ovf',
|
||||
'--disk-format', 'fs',
|
||||
'--min-disk', '10',
|
||||
'--min-ram', '4',
|
||||
'--protected',
|
||||
'--private',
|
||||
image_fakes.image_name,
|
||||
]
|
||||
verifylist = [
|
||||
('container_format', 'ovf'),
|
||||
('disk_format', 'fs'),
|
||||
('min_disk', 10),
|
||||
('min_ram', 4),
|
||||
('protected', True),
|
||||
('unprotected', False),
|
||||
('public', False),
|
||||
('private', True),
|
||||
('name', image_fakes.image_name),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
# DisplayCommandBase.take_action() returns two tuples
|
||||
columns, data = self.cmd.take_action(parsed_args)
|
||||
|
||||
# ImageManager.create(name=, **)
|
||||
self.images_mock.create.assert_called_with(
|
||||
name=image_fakes.image_name,
|
||||
container_format='ovf',
|
||||
disk_format='fs',
|
||||
min_disk=10,
|
||||
min_ram=4,
|
||||
protected=True,
|
||||
visibility='private',
|
||||
)
|
||||
|
||||
# Verify update() was not called, if it was show the args
|
||||
self.assertEqual(self.images_mock.update.call_args_list, [])
|
||||
|
||||
self.images_mock.upload.assert_called_with(
|
||||
mock.ANY, mock.ANY,
|
||||
)
|
||||
|
||||
self.assertEqual(image_fakes.IMAGE_columns, columns)
|
||||
self.assertEqual(image_fakes.IMAGE_data, data)
|
||||
|
||||
@mock.patch('glanceclient.common.utils.get_data_file', name='Open')
|
||||
def test_image_create_file(self, mock_open):
|
||||
mock_file = mock.MagicMock(name='File')
|
||||
mock_open.return_value = mock_file
|
||||
mock_open.read.return_value = image_fakes.IMAGE_data
|
||||
mock_exception = {
|
||||
'find.side_effect': exceptions.CommandError('x'),
|
||||
}
|
||||
self.images_mock.configure_mock(**mock_exception)
|
||||
|
||||
arglist = [
|
||||
'--file', 'filer',
|
||||
'--unprotected',
|
||||
'--public',
|
||||
'--property', 'Alpha=1',
|
||||
'--property', 'Beta=2',
|
||||
'--tag', 'awesome',
|
||||
'--tag', 'better',
|
||||
image_fakes.image_name,
|
||||
]
|
||||
verifylist = [
|
||||
('file', 'filer'),
|
||||
('protected', False),
|
||||
('unprotected', True),
|
||||
('public', True),
|
||||
('private', False),
|
||||
('properties', {'Alpha': '1', 'Beta': '2'}),
|
||||
('tags', ['awesome', 'better']),
|
||||
('name', image_fakes.image_name),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
# DisplayCommandBase.take_action() returns two tuples
|
||||
columns, data = self.cmd.take_action(parsed_args)
|
||||
|
||||
# ImageManager.create(name=, **)
|
||||
self.images_mock.create.assert_called_with(
|
||||
name=image_fakes.image_name,
|
||||
container_format=image.DEFAULT_CONTAINER_FORMAT,
|
||||
disk_format=image.DEFAULT_DISK_FORMAT,
|
||||
protected=False,
|
||||
visibility='public',
|
||||
Alpha='1',
|
||||
Beta='2',
|
||||
tags=['awesome', 'better'],
|
||||
)
|
||||
|
||||
# Verify update() was not called, if it was show the args
|
||||
self.assertEqual(self.images_mock.update.call_args_list, [])
|
||||
|
||||
self.images_mock.upload.assert_called_with(
|
||||
mock.ANY, mock.ANY,
|
||||
)
|
||||
|
||||
self.assertEqual(image_fakes.IMAGE_columns, columns)
|
||||
self.assertEqual(image_fakes.IMAGE_data, data)
|
||||
|
||||
def test_image_dead_options(self):
|
||||
|
||||
arglist = [
|
||||
'--owner', 'nobody',
|
||||
image_fakes.image_name,
|
||||
]
|
||||
verifylist = [
|
||||
('owner', 'nobody'),
|
||||
('name', image_fakes.image_name),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
self.assertRaises(
|
||||
exceptions.CommandError,
|
||||
self.cmd.take_action, parsed_args)
|
||||
|
||||
|
||||
class TestAddProjectToImage(TestImage):
|
||||
|
||||
def setUp(self):
|
||||
|
@ -316,6 +316,7 @@ openstack.image.v1 =
|
||||
|
||||
openstack.image.v2 =
|
||||
image_add_project = openstackclient.image.v2.image:AddProjectToImage
|
||||
image_create = openstackclient.image.v2.image:CreateImage
|
||||
image_delete = openstackclient.image.v2.image:DeleteImage
|
||||
image_list = openstackclient.image.v2.image:ListImage
|
||||
image_remove_project = openstackclient.image.v2.image:RemoveProjectImage
|
||||
|
Loading…
x
Reference in New Issue
Block a user