From 1fb8d1f48b256a2bad78e7d5633ea53c6537907c Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 19 Oct 2022 18:07:41 +0100 Subject: [PATCH] image: Add 'image stage' command This is the equivalent of the 'image-stage' glanceclient command. Change-Id: I10b01ef145740a2f7ffe5a8c7ce0296df0ece0bd Signed-off-by: Stephen Finucane --- doc/source/cli/data/glance.csv | 2 +- openstackclient/image/v2/image.py | 77 +++++++++++++ openstackclient/tests/unit/image/v2/fakes.py | 1 + .../tests/unit/image/v2/test_image.py | 104 +++++++++++++----- .../notes/image-stage-ac19c47e6a52ffeb.yaml | 5 + setup.cfg | 1 + 6 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 releasenotes/notes/image-stage-ac19c47e6a52ffeb.yaml diff --git a/doc/source/cli/data/glance.csv b/doc/source/cli/data/glance.csv index 12b6851d1a..26f720cdb3 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 ,Delete the tag associated with the given image. image-tag-update,image set --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 53cfadede0..039f1d2dc8 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='', + 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='', + 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 cf09df778a..8ce2a7d556 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 ac9ddae6e9..8dea7f05a3 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 0000000000..10bd0497ec --- /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 f8d0dffce5..7900bbe219 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