Merge "image: Add 'image import' command"
This commit is contained in:
commit
29129a7715
@ -4,7 +4,7 @@ image-create-via-import,,EXPERIMENTAL: Create a new image via image import.
|
||||
image-deactivate,image set --deactivate,Deactivate specified image.
|
||||
image-delete,image delete,Delete specified image.
|
||||
image-download,image save,Download a specific image.
|
||||
image-import,,Initiate the image import taskflow.
|
||||
image-import,image import,Initiate the image import taskflow.
|
||||
image-list,image list,List images you can access.
|
||||
image-reactivate,image set --activate,Reactivate specified image.
|
||||
image-show,image show,Describe a specific image.
|
||||
|
|
@ -22,6 +22,7 @@ import os
|
||||
import sys
|
||||
|
||||
from cinderclient import api_versions
|
||||
from openstack import exceptions as sdk_exceptions
|
||||
from openstack.image import image_signer
|
||||
from osc_lib.api import utils as api_utils
|
||||
from osc_lib.cli import format_columns
|
||||
@ -1557,3 +1558,245 @@ class StageImage(command.Command):
|
||||
kwargs['data'] = fp
|
||||
|
||||
image_client.stage_image(image, **kwargs)
|
||||
|
||||
|
||||
class ImportImage(command.ShowOne):
|
||||
_description = _(
|
||||
"Initiate the image import process.\n"
|
||||
"This requires support for the interoperable image import process, "
|
||||
"which was first introduced in Image API version 2.6 "
|
||||
"(Glance 16.0.0 (Queens))"
|
||||
)
|
||||
|
||||
def get_parser(self, prog_name):
|
||||
parser = super().get_parser(prog_name)
|
||||
|
||||
parser.add_argument(
|
||||
'image',
|
||||
metavar='<image>',
|
||||
help=_('Image to initiate import process for (name or ID)'),
|
||||
)
|
||||
# TODO(stephenfin): Uncomment help text when we have this command
|
||||
# implemented
|
||||
parser.add_argument(
|
||||
'--method',
|
||||
metavar='<method>',
|
||||
default='glance-direct',
|
||||
dest='import_method',
|
||||
choices=[
|
||||
'glance-direct',
|
||||
'web-download',
|
||||
'glance-download',
|
||||
'copy-image',
|
||||
],
|
||||
help=_(
|
||||
"Import method used for image import process. "
|
||||
"Not all deployments will support all methods. "
|
||||
# "Valid values can be retrieved with the 'image import "
|
||||
# "methods' command. "
|
||||
"The 'glance-direct' method (default) requires images be "
|
||||
"first staged using the 'image-stage' command."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--uri',
|
||||
metavar='<uri>',
|
||||
help=_(
|
||||
"URI to download the external image "
|
||||
"(only valid with the 'web-download' import method)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--remote-image',
|
||||
metavar='<REMOTE_IMAGE>',
|
||||
help=_(
|
||||
"The image of remote glance (ID only) to be imported "
|
||||
"(only valid with the 'glance-download' import method)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--remote-region',
|
||||
metavar='<REMOTE_GLANCE_REGION>',
|
||||
help=_(
|
||||
"The remote Glance region to download the image from "
|
||||
"(only valid with the 'glance-download' import method)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--remote-service-interface',
|
||||
metavar='<REMOTE_SERVICE_INTERFACE>',
|
||||
help=_(
|
||||
"The remote Glance service interface to use when importing "
|
||||
"images "
|
||||
"(only valid with the 'glance-download' import method)"
|
||||
),
|
||||
)
|
||||
stores_group = parser.add_mutually_exclusive_group()
|
||||
stores_group.add_argument(
|
||||
'--store',
|
||||
metavar='<STORE>',
|
||||
dest='stores',
|
||||
nargs='*',
|
||||
help=_(
|
||||
"Backend store to upload image to "
|
||||
"(specify multiple times to upload to multiple stores) "
|
||||
"(either '--store' or '--all-stores' required with the "
|
||||
"'copy-image' import method)"
|
||||
),
|
||||
)
|
||||
stores_group.add_argument(
|
||||
'--all-stores',
|
||||
help=_(
|
||||
"Make image available to all stores "
|
||||
"(either '--store' or '--all-stores' required with the "
|
||||
"'copy-image' import method)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--allow-failure',
|
||||
action='store_true',
|
||||
dest='allow_failure',
|
||||
default=True,
|
||||
help=_(
|
||||
'When uploading to multiple stores, indicate that the import '
|
||||
'should be continue should any of the uploads fail. '
|
||||
'Only usable with --stores or --all-stores'
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--disallow-failure',
|
||||
action='store_true',
|
||||
dest='allow_failure',
|
||||
default=True,
|
||||
help=_(
|
||||
'When uploading to multiple stores, indicate that the import '
|
||||
'should be reverted should any of the uploads fail. '
|
||||
'Only usable with --stores or --all-stores'
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--wait',
|
||||
action='store_true',
|
||||
help=_('Wait for operation to complete'),
|
||||
)
|
||||
return parser
|
||||
|
||||
def take_action(self, parsed_args):
|
||||
image_client = self.app.client_manager.image
|
||||
|
||||
try:
|
||||
import_info = image_client.get_import_info()
|
||||
except sdk_exceptions.ResourceNotFound:
|
||||
msg = _(
|
||||
'The Image Import feature is not supported by this deployment'
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
import_methods = import_info.import_methods['value']
|
||||
|
||||
if parsed_args.import_method not in import_methods:
|
||||
msg = _(
|
||||
"The '%s' import method is not supported by this deployment. "
|
||||
"Supported: %s"
|
||||
)
|
||||
raise exceptions.CommandError(
|
||||
msg % (parsed_args.import_method, ', '.join(import_methods)),
|
||||
)
|
||||
|
||||
if parsed_args.import_method == 'web-download':
|
||||
if not parsed_args.uri:
|
||||
msg = _(
|
||||
"The '--uri' option is required when using "
|
||||
"'--method=web-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
else:
|
||||
if parsed_args.uri:
|
||||
msg = _(
|
||||
"The '--uri' option is only supported when using "
|
||||
"'--method=web-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
if parsed_args.import_method == 'glance-download':
|
||||
if not (parsed_args.remote_region and parsed_args.remote_image):
|
||||
msg = _(
|
||||
"The '--remote-region' and '--remote-image' options are "
|
||||
"required when using '--method=web-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
else:
|
||||
if parsed_args.remote_region:
|
||||
msg = _(
|
||||
"The '--remote-region' option is only supported when "
|
||||
"using '--method=glance-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
if parsed_args.remote_image:
|
||||
msg = _(
|
||||
"The '--remote-image' option is only supported when using "
|
||||
"'--method=glance-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
if parsed_args.remote_service_interface:
|
||||
msg = _(
|
||||
"The '--remote-service-interface' option is only "
|
||||
"supported when using '--method=glance-download'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
if parsed_args.import_method == 'copy-image':
|
||||
if not (parsed_args.stores or parsed_args.all_stores):
|
||||
msg = _(
|
||||
"The '--stores' or '--all-stores' options are required "
|
||||
"when using '--method=copy-image'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
image = image_client.find_image(parsed_args.image)
|
||||
|
||||
if not image.container_format and not image.disk_format:
|
||||
msg = _(
|
||||
"The 'container_format' and 'disk_format' properties "
|
||||
"must be set on an image before it can be imported"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
if parsed_args.import_method == 'glance-direct':
|
||||
if image.status != 'uploading':
|
||||
msg = _(
|
||||
"The 'glance-direct' import method can only be used with "
|
||||
"an image in status 'uploading'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
elif parsed_args.import_method == 'web-download':
|
||||
if image.status != 'queued':
|
||||
msg = _(
|
||||
"The 'web-download' import method can only be used with "
|
||||
"an image in status 'queued'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
elif parsed_args.import_method == 'copy-image':
|
||||
if image.status != 'active':
|
||||
msg = _(
|
||||
"The 'copy-image' import method can only be used with "
|
||||
"an image in status 'active'"
|
||||
)
|
||||
raise exceptions.CommandError(msg)
|
||||
|
||||
image_client.import_image(
|
||||
image,
|
||||
method=parsed_args.import_method,
|
||||
# uri=parsed_args.uri,
|
||||
# remote_region=parsed_args.remote_region,
|
||||
# remote_image=parsed_args.remote_image,
|
||||
# remote_service_interface=parsed_args.remote_service_interface,
|
||||
stores=parsed_args.stores,
|
||||
all_stores=parsed_args.all_stores,
|
||||
all_stores_must_succeed=not parsed_args.allow_failure,
|
||||
)
|
||||
|
||||
info = _format_image(image)
|
||||
return zip(*sorted(info.items()))
|
||||
|
@ -19,6 +19,7 @@ import uuid
|
||||
from openstack.image.v2 import image
|
||||
from openstack.image.v2 import member
|
||||
from openstack.image.v2 import metadef_namespace
|
||||
from openstack.image.v2 import service_info as _service_info
|
||||
from openstack.image.v2 import task
|
||||
|
||||
from openstackclient.tests.unit import fakes
|
||||
@ -39,6 +40,7 @@ class FakeImagev2Client:
|
||||
self.reactivate_image = mock.Mock()
|
||||
self.deactivate_image = mock.Mock()
|
||||
self.stage_image = mock.Mock()
|
||||
self.import_image = mock.Mock()
|
||||
|
||||
self.members = mock.Mock()
|
||||
self.add_member = mock.Mock()
|
||||
@ -49,17 +51,15 @@ class FakeImagev2Client:
|
||||
self.metadef_namespaces = mock.Mock()
|
||||
|
||||
self.tasks = mock.Mock()
|
||||
self.tasks.resource_class = fakes.FakeResource(None, {})
|
||||
self.get_task = mock.Mock()
|
||||
|
||||
self.get_import_info = mock.Mock()
|
||||
|
||||
self.auth_token = kwargs['token']
|
||||
self.management_url = kwargs['endpoint']
|
||||
self.version = 2.0
|
||||
|
||||
self.tasks = mock.Mock()
|
||||
self.tasks.resource_class = fakes.FakeResource(None, {})
|
||||
|
||||
self.metadef_namespaces = mock.Mock()
|
||||
|
||||
|
||||
class TestImagev2(utils.TestCommand):
|
||||
|
||||
@ -143,6 +143,33 @@ def create_one_image_member(attrs=None):
|
||||
return member.Member(**image_member_info)
|
||||
|
||||
|
||||
def create_one_import_info(attrs=None):
|
||||
"""Create a fake import info.
|
||||
|
||||
:param attrs: A dictionary with all attributes of import info
|
||||
:type attrs: dict
|
||||
:return: A fake Import object.
|
||||
:rtype: `openstack.image.v2.service_info.Import`
|
||||
"""
|
||||
attrs = attrs or {}
|
||||
|
||||
import_info = {
|
||||
'import-methods': {
|
||||
'description': 'Import methods available.',
|
||||
'type': 'array',
|
||||
'value': [
|
||||
'glance-direct',
|
||||
'web-download',
|
||||
'glance-download',
|
||||
'copy-image',
|
||||
]
|
||||
}
|
||||
}
|
||||
import_info.update(attrs)
|
||||
|
||||
return _service_info.Import(**import_info)
|
||||
|
||||
|
||||
def create_one_task(attrs=None):
|
||||
"""Create a fake task.
|
||||
|
||||
|
@ -1823,6 +1823,197 @@ class TestImageStage(TestImage):
|
||||
)
|
||||
|
||||
|
||||
class TestImageImport(TestImage):
|
||||
|
||||
image = image_fakes.create_one_image(
|
||||
{
|
||||
'container_format': 'bare',
|
||||
'disk_format': 'qcow2',
|
||||
}
|
||||
)
|
||||
import_info = image_fakes.create_one_import_info()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.client.find_image.return_value = self.image
|
||||
self.client.get_import_info.return_value = self.import_info
|
||||
|
||||
self.cmd = _image.ImportImage(self.app, None)
|
||||
|
||||
def test_import_image__glance_direct(self):
|
||||
self.image.status = 'uploading'
|
||||
arglist = [
|
||||
self.image.name,
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
self.cmd.take_action(parsed_args)
|
||||
|
||||
self.client.import_image.assert_called_once_with(
|
||||
self.image,
|
||||
method='glance-direct',
|
||||
stores=None,
|
||||
all_stores=None,
|
||||
all_stores_must_succeed=False,
|
||||
)
|
||||
|
||||
def test_import_image__web_download(self):
|
||||
self.image.status = 'queued'
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'web-download',
|
||||
'--uri', 'https://example.com/',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'web-download'),
|
||||
('uri', 'https://example.com/'),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
self.cmd.take_action(parsed_args)
|
||||
|
||||
self.client.import_image.assert_called_once_with(
|
||||
self.image,
|
||||
method='web-download',
|
||||
# uri='https://example.com/',
|
||||
stores=None,
|
||||
all_stores=None,
|
||||
all_stores_must_succeed=False,
|
||||
)
|
||||
|
||||
# NOTE(stephenfin): We don't do this for all combinations since that would
|
||||
# be tedious af. You get the idea...
|
||||
def test_import_image__web_download_missing_options(self):
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'web-download',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'web-download'),
|
||||
('uri', None),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
exc = self.assertRaises(
|
||||
exceptions.CommandError,
|
||||
self.cmd.take_action,
|
||||
parsed_args,
|
||||
)
|
||||
self.assertIn("The '--uri' option is required ", str(exc))
|
||||
|
||||
self.client.import_image.assert_not_called()
|
||||
|
||||
# NOTE(stephenfin): Ditto
|
||||
def test_import_image__web_download_invalid_options(self):
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'glance-direct', # != web-download
|
||||
'--uri', 'https://example.com/',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'glance-direct'),
|
||||
('uri', 'https://example.com/'),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
exc = self.assertRaises(
|
||||
exceptions.CommandError,
|
||||
self.cmd.take_action,
|
||||
parsed_args,
|
||||
)
|
||||
self.assertIn("The '--uri' option is only supported ", str(exc))
|
||||
|
||||
self.client.import_image.assert_not_called()
|
||||
|
||||
def test_import_image__web_download_invalid_image_state(self):
|
||||
self.image.status = 'uploading' # != 'queued'
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'web-download',
|
||||
'--uri', 'https://example.com/',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'web-download'),
|
||||
('uri', 'https://example.com/'),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
exc = self.assertRaises(
|
||||
exceptions.CommandError,
|
||||
self.cmd.take_action,
|
||||
parsed_args,
|
||||
)
|
||||
self.assertIn(
|
||||
"The 'web-download' import method can only be used with "
|
||||
"an image in status 'queued'",
|
||||
str(exc),
|
||||
)
|
||||
|
||||
self.client.import_image.assert_not_called()
|
||||
|
||||
def test_import_image__copy_image(self):
|
||||
self.image.status = 'active'
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'copy-image',
|
||||
'--store', 'fast',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'copy-image'),
|
||||
('stores', ['fast']),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
self.cmd.take_action(parsed_args)
|
||||
|
||||
self.client.import_image.assert_called_once_with(
|
||||
self.image,
|
||||
method='copy-image',
|
||||
stores=['fast'],
|
||||
all_stores=None,
|
||||
all_stores_must_succeed=False,
|
||||
)
|
||||
|
||||
def test_import_image__glance_download(self):
|
||||
arglist = [
|
||||
self.image.name,
|
||||
'--method', 'glance-download',
|
||||
'--remote-region', 'eu/dublin',
|
||||
'--remote-image', 'remote-image-id',
|
||||
'--remote-service-interface', 'private',
|
||||
]
|
||||
verifylist = [
|
||||
('image', self.image.name),
|
||||
('import_method', 'glance-download'),
|
||||
('remote_region', 'eu/dublin'),
|
||||
('remote_image', 'remote-image-id'),
|
||||
('remote_service_interface', 'private'),
|
||||
]
|
||||
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
|
||||
|
||||
self.cmd.take_action(parsed_args)
|
||||
|
||||
self.client.import_image.assert_called_once_with(
|
||||
self.image,
|
||||
method='glance-download',
|
||||
# remote_region='eu/dublin',
|
||||
# remote_image='remote-image-id',
|
||||
# remote_service_interface='private',
|
||||
stores=None,
|
||||
all_stores=None,
|
||||
all_stores_must_succeed=False,
|
||||
)
|
||||
|
||||
|
||||
class TestImageSave(TestImage):
|
||||
|
||||
image = image_fakes.create_one_image({})
|
||||
|
6
releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml
Normal file
6
releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Add ``image import`` command, allowing users to take advantage of the
|
||||
interoperable image import functionality first introduced in Glance 16.0.0
|
||||
(Queens).
|
Loading…
x
Reference in New Issue
Block a user