Merge "Add image create support for image v2"
This commit is contained in:
commit
b2e72e6aee
@ -7,7 +7,7 @@ Image v1, v2
|
|||||||
image create
|
image create
|
||||||
------------
|
------------
|
||||||
|
|
||||||
*Only supported for Image v1*
|
*Image v1, v2*
|
||||||
|
|
||||||
Create/upload an image
|
Create/upload an image
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ Create/upload an image
|
|||||||
[--protected | --unprotected]
|
[--protected | --unprotected]
|
||||||
[--public | --private]
|
[--public | --private]
|
||||||
[--property <key=value> [...] ]
|
[--property <key=value> [...] ]
|
||||||
|
[--tag <tag> [...] ]
|
||||||
<image-name>
|
<image-name>
|
||||||
|
|
||||||
.. option:: --id <id>
|
.. option:: --id <id>
|
||||||
@ -42,6 +43,8 @@ Create/upload an image
|
|||||||
|
|
||||||
Upload image to this store
|
Upload image to this store
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --container-format <container-format>
|
.. option:: --container-format <container-format>
|
||||||
|
|
||||||
Image container format (default: bare)
|
Image container format (default: bare)
|
||||||
@ -54,10 +57,14 @@ Create/upload an image
|
|||||||
|
|
||||||
Image owner project name or ID
|
Image owner project name or ID
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --size <size>
|
.. option:: --size <size>
|
||||||
|
|
||||||
Image size, in bytes (only used with --location and --copy-from)
|
Image size, in bytes (only used with --location and --copy-from)
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --min-disk <disk-gb>
|
.. option:: --min-disk <disk-gb>
|
||||||
|
|
||||||
Minimum disk size needed to boot image, in gigabytes
|
Minimum disk size needed to boot image, in gigabytes
|
||||||
@ -70,10 +77,14 @@ Create/upload an image
|
|||||||
|
|
||||||
Download image from an existing URL
|
Download image from an existing URL
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --copy-from <image-url>
|
.. option:: --copy-from <image-url>
|
||||||
|
|
||||||
Copy image from the data store (similar to --location)
|
Copy image from the data store (similar to --location)
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --file <file>
|
.. option:: --file <file>
|
||||||
|
|
||||||
Upload image from local file
|
Upload image from local file
|
||||||
@ -90,6 +101,8 @@ Create/upload an image
|
|||||||
|
|
||||||
Image hash used for verification
|
Image hash used for verification
|
||||||
|
|
||||||
|
*Image version 1 only.*
|
||||||
|
|
||||||
.. option:: --protected
|
.. option:: --protected
|
||||||
|
|
||||||
Prevent image from being deleted
|
Prevent image from being deleted
|
||||||
@ -110,6 +123,12 @@ Create/upload an image
|
|||||||
|
|
||||||
Set a property on this image (repeat for multiple values)
|
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>
|
.. describe:: <image-name>
|
||||||
|
|
||||||
New image name
|
New image name
|
||||||
|
@ -22,14 +22,19 @@ import six
|
|||||||
from cliff import command
|
from cliff import command
|
||||||
from cliff import lister
|
from cliff import lister
|
||||||
from cliff import show
|
from cliff import show
|
||||||
|
|
||||||
from glanceclient.common import utils as gc_utils
|
from glanceclient.common import utils as gc_utils
|
||||||
|
|
||||||
from openstackclient.api import utils as api_utils
|
from openstackclient.api import utils as api_utils
|
||||||
|
from openstackclient.common import exceptions
|
||||||
from openstackclient.common import parseractions
|
from openstackclient.common import parseractions
|
||||||
from openstackclient.common import utils
|
from openstackclient.common import utils
|
||||||
from openstackclient.identity import common
|
from openstackclient.identity import common
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONTAINER_FORMAT = 'bare'
|
||||||
|
DEFAULT_DISK_FORMAT = 'raw'
|
||||||
|
|
||||||
|
|
||||||
class AddProjectToImage(show.ShowOne):
|
class AddProjectToImage(show.ShowOne):
|
||||||
"""Associate project with image"""
|
"""Associate project with image"""
|
||||||
|
|
||||||
@ -72,6 +77,186 @@ class AddProjectToImage(show.ShowOne):
|
|||||||
return zip(*sorted(six.iteritems(image_member._info)))
|
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):
|
class DeleteImage(command.Command):
|
||||||
"""Delete image(s)"""
|
"""Delete image(s)"""
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ import mock
|
|||||||
import warlock
|
import warlock
|
||||||
|
|
||||||
from glanceclient.v2 import schemas
|
from glanceclient.v2 import schemas
|
||||||
|
from openstackclient.common import exceptions
|
||||||
from openstackclient.image.v2 import image
|
from openstackclient.image.v2 import image
|
||||||
from openstackclient.tests import fakes
|
from openstackclient.tests import fakes
|
||||||
from openstackclient.tests.identity.v3 import fakes as identity_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()
|
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):
|
class TestAddProjectToImage(TestImage):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -316,6 +316,7 @@ openstack.image.v1 =
|
|||||||
|
|
||||||
openstack.image.v2 =
|
openstack.image.v2 =
|
||||||
image_add_project = openstackclient.image.v2.image:AddProjectToImage
|
image_add_project = openstackclient.image.v2.image:AddProjectToImage
|
||||||
|
image_create = openstackclient.image.v2.image:CreateImage
|
||||||
image_delete = openstackclient.image.v2.image:DeleteImage
|
image_delete = openstackclient.image.v2.image:DeleteImage
|
||||||
image_list = openstackclient.image.v2.image:ListImage
|
image_list = openstackclient.image.v2.image:ListImage
|
||||||
image_remove_project = openstackclient.image.v2.image:RemoveProjectImage
|
image_remove_project = openstackclient.image.v2.image:RemoveProjectImage
|
||||||
|
Loading…
x
Reference in New Issue
Block a user