summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorZuul <zuul@review.opendev.org>2022-12-05 12:59:52 +0000
committerGerrit Code Review <review@openstack.org>2022-12-05 12:59:52 +0000
commit29129a7715feb750b7738a5884f9d2f49491a511 (patch)
treece638cf37587869c165ac958ab56fa2f7801a43c
parent8248efa8d9f264bdeb267d3eb666d87edf9b7574 (diff)
parent4eea3408dc492e948671b625ffc4379212b5857c (diff)
downloadpython-openstackclient-29129a7715feb750b7738a5884f9d2f49491a511.tar.gz
Merge "image: Add 'image import' command"
-rw-r--r--doc/source/cli/data/glance.csv2
-rw-r--r--openstackclient/image/v2/image.py243
-rw-r--r--openstackclient/tests/unit/image/v2/fakes.py37
-rw-r--r--openstackclient/tests/unit/image/v2/test_image.py191
-rw-r--r--releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml6
5 files changed, 473 insertions, 6 deletions
diff --git a/doc/source/cli/data/glance.csv b/doc/source/cli/data/glance.csv
index 26f720cd..d5c65f2d 100644
--- a/doc/source/cli/data/glance.csv
+++ b/doc/source/cli/data/glance.csv
@@ -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.
diff --git a/openstackclient/image/v2/image.py b/openstackclient/image/v2/image.py
index 737cf324..21b962f1 100644
--- a/openstackclient/image/v2/image.py
+++ b/openstackclient/image/v2/image.py
@@ -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()))
diff --git a/openstackclient/tests/unit/image/v2/fakes.py b/openstackclient/tests/unit/image/v2/fakes.py
index 8ce2a7d5..ded9ff31 100644
--- a/openstackclient/tests/unit/image/v2/fakes.py
+++ b/openstackclient/tests/unit/image/v2/fakes.py
@@ -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.
diff --git a/openstackclient/tests/unit/image/v2/test_image.py b/openstackclient/tests/unit/image/v2/test_image.py
index 8dea7f05..010c4a9d 100644
--- a/openstackclient/tests/unit/image/v2/test_image.py
+++ b/openstackclient/tests/unit/image/v2/test_image.py
@@ -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({})
diff --git a/releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml b/releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml
new file mode 100644
index 00000000..0c394c82
--- /dev/null
+++ b/releasenotes/notes/image-import-d5da3e5ce8733fb0.yaml
@@ -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).