summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStephen Finucane <sfinucan@redhat.com>2022-10-19 18:07:41 +0100
committerStephen Finucane <sfinucan@redhat.com>2022-11-09 16:51:54 +0000
commit1fb8d1f48b256a2bad78e7d5633ea53c6537907c (patch)
tree17331af0f0209f156d91ea1ccf14c1bf8b2df5b3
parent3d9a9df935af1f76a33d008fe76975475c77a268 (diff)
downloadpython-openstackclient-1fb8d1f48b256a2bad78e7d5633ea53c6537907c.tar.gz
image: Add 'image stage' command
This is the equivalent of the 'image-stage' glanceclient command. Change-Id: I10b01ef145740a2f7ffe5a8c7ce0296df0ece0bd Signed-off-by: Stephen Finucane <sfinucan@redhat.com>
-rw-r--r--doc/source/cli/data/glance.csv2
-rw-r--r--openstackclient/image/v2/image.py77
-rw-r--r--openstackclient/tests/unit/image/v2/fakes.py1
-rw-r--r--openstackclient/tests/unit/image/v2/test_image.py104
-rw-r--r--releasenotes/notes/image-stage-ac19c47e6a52ffeb.yaml5
-rw-r--r--setup.cfg1
6 files changed, 164 insertions, 26 deletions
diff --git a/doc/source/cli/data/glance.csv b/doc/source/cli/data/glance.csv
index 12b6851d..26f720cd 100644
--- a/doc/source/cli/data/glance.csv
+++ b/doc/source/cli/data/glance.csv
@@ -8,7 +8,7 @@ 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.
-image-stage,,Upload data for a specific image to staging.
+image-stage,image stage,Upload data for a specific image to staging.
image-tag-delete,image unset --tag <tag>,Delete the tag associated with the given image.
image-tag-update,image set --tag <tag>,Update an image with the given tag.
image-update,image set,Update an existing image.
diff --git a/openstackclient/image/v2/image.py b/openstackclient/image/v2/image.py
index 53cfaded..039f1d2d 100644
--- a/openstackclient/image/v2/image.py
+++ b/openstackclient/image/v2/image.py
@@ -1484,3 +1484,80 @@ class UnsetImage(command.Command):
"Failed to unset %(propret)s of %(proptotal)s" " properties."
) % {'propret': propret, 'proptotal': proptotal}
raise exceptions.CommandError(msg)
+
+
+class StageImage(command.Command):
+ _description = _(
+ "Upload data for a specific image to staging.\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(
+ '--file',
+ metavar='<file>',
+ dest='filename',
+ help=_(
+ 'Local file that contains disk image to be uploaded. '
+ 'Alternatively, images can be passed via stdin.'
+ ),
+ )
+ # NOTE(stephenfin): glanceclient had a --size argument but it didn't do
+ # anything so we have chosen not to port this
+ parser.add_argument(
+ '--progress',
+ action='store_true',
+ default=False,
+ help=_(
+ 'Show upload progress bar '
+ '(ignored if passing data via stdin)'
+ ),
+ )
+ parser.add_argument(
+ 'image',
+ metavar='<image>',
+ help=_('Image to upload data for (name or ID)'),
+ )
+
+ return parser
+
+ def take_action(self, parsed_args):
+ image_client = self.app.client_manager.image
+
+ image = image_client.find_image(
+ parsed_args.image,
+ ignore_missing=False,
+ )
+ # open the file first to ensure any failures are handled before the
+ # image is created. Get the file name (if it is file, and not stdin)
+ # for easier further handling.
+ if parsed_args.filename:
+ try:
+ fp = open(parsed_args.filename, 'rb')
+ except FileNotFoundError:
+ raise exceptions.CommandError(
+ '%r is not a valid file' % parsed_args.filename,
+ )
+ else:
+ fp = get_data_from_stdin()
+
+ kwargs = {}
+
+ if parsed_args.progress and parsed_args.filename:
+ # NOTE(stephenfin): we only show a progress bar if the user
+ # requested it *and* we're reading from a file (not stdin)
+ filesize = os.path.getsize(parsed_args.filename)
+ if filesize is not None:
+ kwargs['data'] = progressbar.VerboseFileWrapper(fp, filesize)
+ else:
+ kwargs['data'] = fp
+ elif parsed_args.filename:
+ kwargs['filename'] = parsed_args.filename
+ elif fp:
+ kwargs['data'] = fp
+
+ image_client.stage_image(image, **kwargs)
diff --git a/openstackclient/tests/unit/image/v2/fakes.py b/openstackclient/tests/unit/image/v2/fakes.py
index cf09df77..8ce2a7d5 100644
--- a/openstackclient/tests/unit/image/v2/fakes.py
+++ b/openstackclient/tests/unit/image/v2/fakes.py
@@ -38,6 +38,7 @@ class FakeImagev2Client:
self.download_image = mock.Mock()
self.reactivate_image = mock.Mock()
self.deactivate_image = mock.Mock()
+ self.stage_image = mock.Mock()
self.members = mock.Mock()
self.add_member = mock.Mock()
diff --git a/openstackclient/tests/unit/image/v2/test_image.py b/openstackclient/tests/unit/image/v2/test_image.py
index ac9ddae6..8dea7f05 100644
--- a/openstackclient/tests/unit/image/v2/test_image.py
+++ b/openstackclient/tests/unit/image/v2/test_image.py
@@ -22,7 +22,7 @@ from openstack import exceptions as sdk_exceptions
from osc_lib.cli import format_columns
from osc_lib import exceptions
-from openstackclient.image.v2 import image
+from openstackclient.image.v2 import image as _image
from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes
from openstackclient.tests.unit.image.v2 import fakes as image_fakes
from openstackclient.tests.unit.volume.v3 import fakes as volume_fakes
@@ -73,10 +73,10 @@ class TestImageCreate(TestImage):
self.client.update_image.return_value = self.new_image
(self.expected_columns, self.expected_data) = zip(
- *sorted(image._format_image(self.new_image).items()))
+ *sorted(_image._format_image(self.new_image).items()))
# Get the command object to test
- self.cmd = image.CreateImage(self.app, None)
+ self.cmd = _image.CreateImage(self.app, None)
@mock.patch("sys.stdin", side_effect=[None])
def test_image_reserve_no_options(self, raw_input):
@@ -84,8 +84,8 @@ class TestImageCreate(TestImage):
self.new_image.name
]
verifylist = [
- ('container_format', image.DEFAULT_CONTAINER_FORMAT),
- ('disk_format', image.DEFAULT_DISK_FORMAT),
+ ('container_format', _image.DEFAULT_CONTAINER_FORMAT),
+ ('disk_format', _image.DEFAULT_DISK_FORMAT),
('name', self.new_image.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
@@ -99,8 +99,8 @@ class TestImageCreate(TestImage):
self.client.create_image.assert_called_with(
name=self.new_image.name,
allow_duplicates=True,
- container_format=image.DEFAULT_CONTAINER_FORMAT,
- disk_format=image.DEFAULT_DISK_FORMAT,
+ container_format=_image.DEFAULT_CONTAINER_FORMAT,
+ disk_format=_image.DEFAULT_DISK_FORMAT,
)
self.assertEqual(self.expected_columns, columns)
@@ -224,8 +224,8 @@ class TestImageCreate(TestImage):
self.client.create_image.assert_called_with(
name=self.new_image.name,
allow_duplicates=True,
- container_format=image.DEFAULT_CONTAINER_FORMAT,
- disk_format=image.DEFAULT_DISK_FORMAT,
+ container_format=_image.DEFAULT_CONTAINER_FORMAT,
+ disk_format=_image.DEFAULT_DISK_FORMAT,
is_protected=self.new_image.is_protected,
visibility=self.new_image.visibility,
Alpha='1',
@@ -245,7 +245,7 @@ class TestImageCreate(TestImage):
def test_image_create__progress_ignore_with_stdin(
self, mock_get_data_from_stdin,
):
- fake_stdin = io.StringIO('fake-image-data')
+ fake_stdin = io.BytesIO(b'some fake data')
mock_get_data_from_stdin.return_value = fake_stdin
arglist = [
@@ -263,8 +263,8 @@ class TestImageCreate(TestImage):
self.client.create_image.assert_called_with(
name=self.new_image.name,
allow_duplicates=True,
- container_format=image.DEFAULT_CONTAINER_FORMAT,
- disk_format=image.DEFAULT_DISK_FORMAT,
+ container_format=_image.DEFAULT_CONTAINER_FORMAT,
+ disk_format=_image.DEFAULT_DISK_FORMAT,
data=fake_stdin,
validate_checksum=False,
)
@@ -305,8 +305,8 @@ class TestImageCreate(TestImage):
self.client.create_image.assert_called_with(
name=self.new_image.name,
allow_duplicates=True,
- container_format=image.DEFAULT_CONTAINER_FORMAT,
- disk_format=image.DEFAULT_DISK_FORMAT,
+ container_format=_image.DEFAULT_CONTAINER_FORMAT,
+ disk_format=_image.DEFAULT_DISK_FORMAT,
use_import=True
)
@@ -445,7 +445,7 @@ class TestAddProjectToImage(TestImage):
self.project_mock.get.return_value = self.project
self.domain_mock.get.return_value = self.domain
# Get the command object to test
- self.cmd = image.AddProjectToImage(self.app, None)
+ self.cmd = _image.AddProjectToImage(self.app, None)
def test_add_project_to_image_no_option(self):
arglist = [
@@ -504,7 +504,7 @@ class TestImageDelete(TestImage):
self.client.delete_image.return_value = None
# Get the command object to test
- self.cmd = image.DeleteImage(self.app, None)
+ self.cmd = _image.DeleteImage(self.app, None)
def test_image_delete_no_options(self):
images = self.setup_images_mock(count=1)
@@ -595,7 +595,7 @@ class TestImageList(TestImage):
self.client.images.side_effect = [[self._image], []]
# Get the command object to test
- self.cmd = image.ListImage(self.app, None)
+ self.cmd = _image.ListImage(self.app, None)
def test_image_list_no_options(self):
arglist = []
@@ -993,7 +993,7 @@ class TestListImageProjects(TestImage):
self.client.find_image.return_value = self._image
self.client.members.return_value = [self.member]
- self.cmd = image.ListImageProjects(self.app, None)
+ self.cmd = _image.ListImageProjects(self.app, None)
def test_image_member_list(self):
arglist = [
@@ -1028,7 +1028,7 @@ class TestRemoveProjectImage(TestImage):
self.domain_mock.get.return_value = self.domain
self.client.remove_member.return_value = None
# Get the command object to test
- self.cmd = image.RemoveProjectImage(self.app, None)
+ self.cmd = _image.RemoveProjectImage(self.app, None)
def test_remove_project_image_no_options(self):
arglist = [
@@ -1095,7 +1095,7 @@ class TestImageSet(TestImage):
)
# Get the command object to test
- self.cmd = image.SetImage(self.app, None)
+ self.cmd = _image.SetImage(self.app, None)
def test_image_set_no_options(self):
arglist = [
@@ -1624,7 +1624,7 @@ class TestImageShow(TestImage):
self.client.find_image = mock.Mock(return_value=self._data)
# Get the command object to test
- self.cmd = image.ShowImage(self.app, None)
+ self.cmd = _image.ShowImage(self.app, None)
def test_image_show(self):
arglist = [
@@ -1689,7 +1689,7 @@ class TestImageUnset(TestImage):
self.client.update_image.return_value = self.image
# Get the command object to test
- self.cmd = image.UnsetImage(self.app, None)
+ self.cmd = _image.UnsetImage(self.app, None)
def test_image_unset_no_options(self):
arglist = [
@@ -1769,6 +1769,60 @@ class TestImageUnset(TestImage):
self.assertIsNone(result)
+class TestImageStage(TestImage):
+
+ image = image_fakes.create_one_image({})
+
+ def setUp(self):
+ super().setUp()
+
+ self.client.find_image.return_value = self.image
+
+ self.cmd = _image.StageImage(self.app, None)
+
+ def test_stage_image__from_file(self):
+ imagefile = tempfile.NamedTemporaryFile(delete=False)
+ imagefile.write(b'\0')
+ imagefile.close()
+
+ arglist = [
+ '--file', imagefile.name,
+ self.image.name,
+ ]
+ verifylist = [
+ ('filename', imagefile.name),
+ ('image', self.image.name),
+ ]
+ parsed_args = self.check_parser(self.cmd, arglist, verifylist)
+
+ self.cmd.take_action(parsed_args)
+
+ self.client.stage_image.assert_called_once_with(
+ self.image,
+ filename=imagefile.name,
+ )
+
+ @mock.patch('openstackclient.image.v2.image.get_data_from_stdin')
+ def test_stage_image__from_stdin(self, mock_get_data_from_stdin):
+ fake_stdin = io.BytesIO(b"some initial binary data: \x00\x01")
+ mock_get_data_from_stdin.return_value = fake_stdin
+
+ 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.stage_image.assert_called_once_with(
+ self.image,
+ data=fake_stdin,
+ )
+
+
class TestImageSave(TestImage):
image = image_fakes.create_one_image({})
@@ -1780,7 +1834,7 @@ class TestImageSave(TestImage):
self.client.download_image.return_value = self.image
# Get the command object to test
- self.cmd = image.SaveImage(self.app, None)
+ self.cmd = _image.SaveImage(self.app, None)
def test_save_data(self):
@@ -1810,7 +1864,7 @@ class TestImageGetData(TestImage):
stdin.isatty.return_value = False
stdin.buffer = fd
- test_fd = image.get_data_from_stdin()
+ test_fd = _image.get_data_from_stdin()
# Ensure data written to temp file is correct
self.assertEqual(fd, test_fd)
@@ -1822,6 +1876,6 @@ class TestImageGetData(TestImage):
# There is stdin, but interactive
stdin.return_value = fd
- test_fd = image.get_data_from_stdin()
+ test_fd = _image.get_data_from_stdin()
self.assertIsNone(test_fd)
diff --git a/releasenotes/notes/image-stage-ac19c47e6a52ffeb.yaml b/releasenotes/notes/image-stage-ac19c47e6a52ffeb.yaml
new file mode 100644
index 00000000..10bd0497
--- /dev/null
+++ b/releasenotes/notes/image-stage-ac19c47e6a52ffeb.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Added a new command, ``image stage``, that will allow users to upload data
+ for an image to staging.
diff --git a/setup.cfg b/setup.cfg
index f8d0dffc..7900bbe2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -383,6 +383,7 @@ openstack.image.v2 =
image_show = openstackclient.image.v2.image:ShowImage
image_set = openstackclient.image.v2.image:SetImage
image_unset = openstackclient.image.v2.image:UnsetImage
+ image_stage = openstackclient.image.v2.image:StageImage
image_task_show = openstackclient.image.v2.task:ShowTask
image_task_list = openstackclient.image.v2.task:ListTask
image_metadef_namespace_list = openstackclient.image.v2.metadef_namespaces:ListMetadefNameSpaces