Skip to content

Commit 3e8727c

Browse files
committed
Add support for passing image size to Glance API
Add a --size option to the ``openstack image create`` and ``openstack image stage`` commands so users can specify the size of image data being uploaded. Providing this can improve upload performance. When omitted, openstacksdk calculates the size automatically when possible. Assisted-By: Cursor (claude-4.5-sonnet) Change-Id: Ie92c4544e058f5c12c485595554c084772982a7b Signed-off-by: Abhishek Kekane <akekane@redhat.com>
1 parent 4a823a5 commit 3e8727c

3 files changed

Lines changed: 225 additions & 4 deletions

File tree

openstackclient/image/v2/image.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ def _get_member_columns(item: Any) -> tuple[tuple[str, ...], tuple[str, ...]]:
151151
)
152152

153153

154+
def _parse_image_size(value: str) -> int:
155+
try:
156+
size = int(value)
157+
except ValueError:
158+
raise argparse.ArgumentTypeError(
159+
_("'%(value)s' is not a valid size (use a positive integer)")
160+
% {'value': value}
161+
)
162+
if size <= 0:
163+
raise argparse.ArgumentTypeError(_("Size must be a positive integer"))
164+
return size
165+
166+
154167
def get_data_from_stdin() -> Any:
155168
# distinguish cases where:
156169
# (1) stdin is not valid (as in cron jobs):
@@ -291,15 +304,14 @@ def take_action(
291304
class CreateImage(command.ShowOne):
292305
_description = _("Create/upload an image")
293306

294-
deadopts = ('size', 'location', 'copy-from', 'checksum', 'store')
307+
deadopts = ('location', 'copy-from', 'checksum', 'store')
295308

296309
def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
297310
parser = super().get_parser(prog_name)
298311
# TODO(bunting): There are additional arguments that v1 supported
299312
# that v2 either doesn't support or supports weirdly.
300313
# --checksum - could be faked clientside perhaps?
301314
# --location - maybe location add?
302-
# --size - passing image size is actually broken in python-glanceclient
303315
# --copy-from - does not exist in v2
304316
# --store - does not exits in v2
305317
parser.add_argument(
@@ -352,6 +364,15 @@ def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
352364
type=int,
353365
help=_("Minimum RAM size needed to boot image, in megabytes"),
354366
)
367+
parser.add_argument(
368+
"--size",
369+
metavar="<size>",
370+
type=_parse_image_size,
371+
help=_(
372+
"Size of image data in bytes. Providing this can improve "
373+
"upload performance."
374+
),
375+
)
355376
source_group = parser.add_mutually_exclusive_group()
356377
source_group.add_argument(
357378
"--file",
@@ -520,6 +541,10 @@ def _take_action_image(
520541
)
521542
raise exceptions.CommandError(msg)
522543

544+
if parsed_args.size is not None and fp is None:
545+
msg = _("--size requires image data via --file or stdin")
546+
raise exceptions.CommandError(msg)
547+
523548
if parsed_args.progress and parsed_args.filename:
524549
# NOTE(stephenfin): we only show a progress bar if the user
525550
# requested it *and* we're reading from a file (not stdin)
@@ -590,6 +615,11 @@ def _take_action_image(
590615
if signer.padding_method:
591616
kwargs['img_signature_key_type'] = signer.padding_method
592617

618+
# Pass size only when uploading data. The SDK calculates size
619+
# automatically when possible if it is not provided.
620+
if parsed_args.size is not None:
621+
kwargs['size'] = parsed_args.size
622+
593623
image = image_client.create_image(**kwargs)
594624

595625
if parsed_args.filename:
@@ -1608,8 +1638,15 @@ def get_parser(self, prog_name: str) -> argparse.ArgumentParser:
16081638
'Alternatively, images can be passed via stdin.'
16091639
),
16101640
)
1611-
# NOTE(stephenfin): glanceclient had a --size argument but it didn't do
1612-
# anything so we have chosen not to port this
1641+
parser.add_argument(
1642+
'--size',
1643+
metavar='<size>',
1644+
type=_parse_image_size,
1645+
help=_(
1646+
'Size of image data in bytes. Providing this can improve '
1647+
'upload performance.'
1648+
),
1649+
)
16131650
parser.add_argument(
16141651
'--progress',
16151652
action='store_true',
@@ -1646,6 +1683,10 @@ def take_action(self, parsed_args: argparse.Namespace) -> None:
16461683
else:
16471684
fp = get_data_from_stdin()
16481685

1686+
if parsed_args.size is not None and fp is None:
1687+
msg = _("--size requires image data via --file or stdin")
1688+
raise exceptions.CommandError(msg)
1689+
16491690
kwargs: dict[str, Any] = {}
16501691

16511692
if parsed_args.progress and parsed_args.filename:
@@ -1661,6 +1702,11 @@ def take_action(self, parsed_args: argparse.Namespace) -> None:
16611702
elif fp:
16621703
kwargs['data'] = fp
16631704

1705+
# Pass size only when uploading data. The SDK calculates size
1706+
# automatically when possible if it is not provided.
1707+
if parsed_args.size is not None:
1708+
kwargs['size'] = parsed_args.size
1709+
16641710
image_client.stage_image(image, **kwargs)
16651711

16661712

openstackclient/tests/unit/image/v2/test_image.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from openstackclient.image.v2 import image as _image
2929
from openstackclient.tests.unit.image.v2 import fakes as image_fakes
30+
from openstackclient.tests.unit import utils as test_utils
3031
from openstackclient.tests.unit.volume.v3 import fakes as volume_fakes
3132

3233

@@ -235,6 +236,128 @@ def test_image_create_file(self):
235236
self.assertEqual(self.expected_columns, columns)
236237
self.assertCountEqual(self.expected_data, data)
237238

239+
def test_image_create_file_with_size(self):
240+
imagefile = tempfile.NamedTemporaryFile(delete=False)
241+
imagefile.write(b'\0')
242+
imagefile.close()
243+
244+
arglist = [
245+
'--file',
246+
imagefile.name,
247+
'--size',
248+
'2048',
249+
(
250+
'--unprotected'
251+
if not self.new_image.is_protected
252+
else '--protected'
253+
),
254+
(
255+
'--public'
256+
if self.new_image.visibility == 'public'
257+
else '--private'
258+
),
259+
'--property',
260+
'Alpha=1',
261+
'--property',
262+
'Beta=2',
263+
'--tag',
264+
self.new_image.tags[0],
265+
'--tag',
266+
self.new_image.tags[1],
267+
self.new_image.name,
268+
]
269+
verifylist = [
270+
('filename', imagefile.name),
271+
('size', 2048),
272+
('is_protected', self.new_image.is_protected),
273+
('visibility', self.new_image.visibility),
274+
('properties', {'Alpha': '1', 'Beta': '2'}),
275+
('tags', self.new_image.tags),
276+
('name', self.new_image.name),
277+
]
278+
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
279+
280+
columns, data = self.cmd.take_action(parsed_args)
281+
282+
self.image_client.create_image.assert_called_with(
283+
name=self.new_image.name,
284+
allow_duplicates=True,
285+
container_format=_image.DEFAULT_CONTAINER_FORMAT,
286+
disk_format=_image.DEFAULT_DISK_FORMAT,
287+
is_protected=self.new_image.is_protected,
288+
visibility=self.new_image.visibility,
289+
Alpha='1',
290+
Beta='2',
291+
tags=self.new_image.tags,
292+
filename=imagefile.name,
293+
size=2048,
294+
)
295+
self.image_client.get_image.assert_called_once_with(self.new_image)
296+
297+
self.assertEqual(self.expected_columns, columns)
298+
self.assertCountEqual(self.expected_data, data)
299+
300+
@mock.patch('sys.stdin', side_effect=[None])
301+
def test_image_create_size_requires_upload(self, raw_input):
302+
arglist = [
303+
'--size',
304+
'2048',
305+
self.new_image.name,
306+
]
307+
verifylist = [
308+
('size', 2048),
309+
('name', self.new_image.name),
310+
]
311+
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
312+
313+
self.assertRaises(
314+
exceptions.CommandError,
315+
self.cmd.take_action,
316+
parsed_args,
317+
)
318+
319+
def test_image_create_size_must_be_positive(self):
320+
arglist = [
321+
'--size',
322+
'0',
323+
self.new_image.name,
324+
]
325+
self.assertRaises(
326+
test_utils.ParserException,
327+
self.check_parser,
328+
self.cmd,
329+
arglist,
330+
[],
331+
)
332+
333+
@mock.patch('openstackclient.image.v2.image.get_data_from_stdin')
334+
def test_image_create_stdin_with_size(self, mock_get_data_from_stdin):
335+
fake_stdin = io.BytesIO(b'some fake data')
336+
mock_get_data_from_stdin.return_value = fake_stdin
337+
338+
arglist = [
339+
'--size',
340+
'2048',
341+
self.new_image.name,
342+
]
343+
verifylist = [
344+
('size', 2048),
345+
('name', self.new_image.name),
346+
]
347+
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
348+
349+
self.cmd.take_action(parsed_args)
350+
351+
self.image_client.create_image.assert_called_with(
352+
name=self.new_image.name,
353+
allow_duplicates=True,
354+
container_format=_image.DEFAULT_CONTAINER_FORMAT,
355+
disk_format=_image.DEFAULT_DISK_FORMAT,
356+
data=fake_stdin,
357+
validate_checksum=False,
358+
size=2048,
359+
)
360+
238361
@mock.patch('openstackclient.image.v2.image.get_data_from_stdin')
239362
def test_image_create__progress_ignore_with_stdin(
240363
self,
@@ -2033,6 +2156,52 @@ def test_stage_image__from_stdin(self, mock_get_data_from_stdin):
20332156
data=fake_stdin,
20342157
)
20352158

2159+
def test_stage_image__with_size(self):
2160+
imagefile = tempfile.NamedTemporaryFile(delete=False)
2161+
imagefile.write(b'\0' * 1024)
2162+
imagefile.close()
2163+
2164+
arglist = [
2165+
'--file',
2166+
imagefile.name,
2167+
'--size',
2168+
'2048',
2169+
self.image.name,
2170+
]
2171+
verifylist = [
2172+
('filename', imagefile.name),
2173+
('size', 2048),
2174+
('image', self.image.name),
2175+
]
2176+
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
2177+
2178+
self.cmd.take_action(parsed_args)
2179+
2180+
self.image_client.stage_image.assert_called_once_with(
2181+
self.image,
2182+
filename=imagefile.name,
2183+
size=2048,
2184+
)
2185+
2186+
@mock.patch('sys.stdin', side_effect=[None])
2187+
def test_stage_image__size_requires_upload(self, raw_input):
2188+
arglist = [
2189+
'--size',
2190+
'2048',
2191+
self.image.name,
2192+
]
2193+
verifylist = [
2194+
('size', 2048),
2195+
('image', self.image.name),
2196+
]
2197+
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
2198+
2199+
self.assertRaises(
2200+
exceptions.CommandError,
2201+
self.cmd.take_action,
2202+
parsed_args,
2203+
)
2204+
20362205

20372206
class TestImageImport(image_fakes.TestImagev2):
20382207
image = image_fakes.create_one_image(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
features:
3+
- |
4+
The ``openstack image create`` and ``openstack image stage`` commands
5+
now accept a ``--size`` option to specify the size of image data being
6+
uploaded in bytes. Providing this can improve upload performance.

0 commit comments

Comments
 (0)