Merge "Complete switch from glanceclient to SDK for image service"

This commit is contained in:
Zuul 2020-03-25 15:19:21 +00:00 committed by Gerrit Code Review
commit 74616cd235
6 changed files with 155 additions and 229 deletions

View File

@ -26,64 +26,18 @@ DEFAULT_API_VERSION = '2'
API_VERSION_OPTION = 'os_image_api_version'
API_NAME = "image"
API_VERSIONS = {
"1": "glanceclient.v1.client.Client",
"1": "openstack.connection.Connection",
"2": "openstack.connection.Connection",
}
IMAGE_API_TYPE = 'image'
IMAGE_API_VERSIONS = {
'1': 'openstackclient.api.image_v1.APIv1',
'2': 'openstackclient.api.image_v2.APIv2',
}
def make_client(instance):
if instance._api_version[API_NAME] != '1':
LOG.debug(
'Image client initialized using OpenStack SDK: %s',
instance.sdk_connection.image,
)
return instance.sdk_connection.image
else:
"""Returns an image service client"""
image_client = utils.get_client_class(
API_NAME,
instance._api_version[API_NAME],
API_VERSIONS)
LOG.debug('Instantiating image client: %s', image_client)
endpoint = instance.get_endpoint_for_service_type(
API_NAME,
region_name=instance.region_name,
interface=instance.interface,
)
client = image_client(
endpoint,
token=instance.auth.get_token(instance.session),
cacert=instance.cacert,
insecure=not instance.verify,
)
# Create the low-level API
image_api = utils.get_client_class(
API_NAME,
instance._api_version[API_NAME],
IMAGE_API_VERSIONS)
LOG.debug('Instantiating image api: %s', image_api)
client.api = image_api(
session=instance.session,
endpoint=instance.get_endpoint_for_service_type(
IMAGE_API_TYPE,
region_name=instance.region_name,
interface=instance.interface,
)
)
return client
LOG.debug(
'Image client initialized using OpenStack SDK: %s',
instance.sdk_connection.image,
)
return instance.sdk_connection.image
def build_option_parser(parser):

View File

@ -22,13 +22,13 @@ import os
import sys
from cliff import columns as cliff_columns
from glanceclient.common import utils as gc_utils
from osc_lib.api import utils as api_utils
from osc_lib.cli import format_columns
from osc_lib.cli import parseractions
from osc_lib.command import command
from osc_lib import utils
from openstackclient.common import sdk_utils
from openstackclient.i18n import _
if os.name == "nt":
@ -47,6 +47,36 @@ DISK_CHOICES = ["ami", "ari", "aki", "vhd", "vmdk", "raw", "qcow2", "vhdx",
LOG = logging.getLogger(__name__)
def _get_columns(item):
# Trick sdk_utils to return URI attribute
column_map = {
'is_protected': 'protected',
'owner_id': 'owner'
}
hidden_columns = ['location', 'checksum',
'copy_from', 'created_at', 'status', 'updated_at']
return sdk_utils.get_osc_show_columns_for_sdk_resource(
item.to_dict(), column_map, hidden_columns)
_formatters = {
}
class HumanReadableSizeColumn(cliff_columns.FormattableColumn):
def human_readable(self):
"""Return a formatted visibility string
:rtype:
A string formatted to public/private
"""
if self._value:
return utils.format_size(self._value)
else:
return ''
class VisibilityColumn(cliff_columns.FormattableColumn):
def human_readable(self):
"""Return a formatted visibility string
@ -210,7 +240,7 @@ class CreateImage(command.ShowOne):
# Special case project option back to API attribute name 'owner'
val = getattr(parsed_args, 'project', None)
if val:
kwargs['owner'] = val
kwargs['owner_id'] = val
# Handle exclusive booleans with care
# Avoid including attributes in kwargs if an option is not
@ -219,9 +249,9 @@ class CreateImage(command.ShowOne):
# to do nothing when no options are present as opposed to always
# setting a default.
if parsed_args.protected:
kwargs['protected'] = True
kwargs['is_protected'] = True
if parsed_args.unprotected:
kwargs['protected'] = False
kwargs['is_protected'] = False
if parsed_args.public:
kwargs['is_public'] = True
if parsed_args.private:
@ -250,27 +280,35 @@ class CreateImage(command.ShowOne):
kwargs["data"] = io.open(parsed_args.file, "rb")
else:
# Read file from stdin
if sys.stdin.isatty() is not True:
if not sys.stdin.isatty():
if msvcrt:
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
# Send an open file handle to glanceclient so it will
# do a chunked transfer
kwargs["data"] = sys.stdin
if hasattr(sys.stdin, 'buffer'):
kwargs['data'] = sys.stdin.buffer
else:
kwargs["data"] = sys.stdin
if not parsed_args.volume:
# Wrap the call to catch exceptions in order to close files
try:
image = image_client.images.create(**kwargs)
image = image_client.create_image(**kwargs)
finally:
# Clean up open files - make sure data isn't a string
if ('data' in kwargs and hasattr(kwargs['data'], 'close') and
kwargs['data'] != sys.stdin):
kwargs['data'].close()
if image:
display_columns, columns = _get_columns(image)
_formatters['properties'] = format_columns.DictColumn
data = utils.get_item_properties(image, columns,
formatters=_formatters)
return (display_columns, data)
elif info:
info.update(image._info)
info['properties'] = format_columns.DictColumn(
info.get('properties', {}))
return zip(*sorted(info.items()))
return zip(*sorted(info.items()))
class DeleteImage(command.Command):
@ -289,11 +327,8 @@ class DeleteImage(command.Command):
def take_action(self, parsed_args):
image_client = self.app.client_manager.image
for image in parsed_args.images:
image_obj = utils.find_resource(
image_client.images,
image,
)
image_client.images.delete(image_obj.id)
image_obj = image_client.find_image(image)
image_client.delete_image(image_obj.id)
class ListImage(command.Lister):
@ -359,15 +394,9 @@ class ListImage(command.Lister):
kwargs = {}
if parsed_args.public:
kwargs['public'] = True
kwargs['is_public'] = True
if parsed_args.private:
kwargs['private'] = True
# Note: We specifically need to do that below to get the 'status'
# column.
#
# Always set kwargs['detailed'] to True, and then filter the columns
# according to whether the --long option is specified or not.
kwargs['detailed'] = True
kwargs['is_private'] = True
if parsed_args.long:
columns = (
@ -379,8 +408,8 @@ class ListImage(command.Lister):
'Checksum',
'Status',
'is_public',
'protected',
'owner',
'is_protected',
'owner_id',
'properties',
)
column_headers = (
@ -401,16 +430,7 @@ class ListImage(command.Lister):
column_headers = columns
# List of image data received
data = []
# No pages received yet, so start the page marker at None.
marker = None
while True:
page = image_client.api.image_list(marker=marker, **kwargs)
if not page:
break
data.extend(page)
# Set the marker to the id of the last item we received
marker = page[-1]['id']
data = list(image_client.images(**kwargs))
if parsed_args.property:
# NOTE(dtroyer): coerce to a list to subscript it in py3
@ -426,7 +446,7 @@ class ListImage(command.Lister):
return (
column_headers,
(utils.get_dict_properties(
(utils.get_item_properties(
s,
columns,
formatters={
@ -456,13 +476,9 @@ class SaveImage(command.Command):
def take_action(self, parsed_args):
image_client = self.app.client_manager.image
image = utils.find_resource(
image_client.images,
parsed_args.image,
)
data = image_client.images.data(image)
image = image_client.find_image(parsed_args.image)
gc_utils.save_image(data, parsed_args.file)
image_client.download_image(image.id, output=parsed_args.file)
class SetImage(command.Command):
@ -621,22 +637,17 @@ class SetImage(command.Command):
# to do nothing when no options are present as opposed to always
# setting a default.
if parsed_args.protected:
kwargs['protected'] = True
kwargs['is_protected'] = True
if parsed_args.unprotected:
kwargs['protected'] = False
kwargs['is_protected'] = False
if parsed_args.public:
kwargs['is_public'] = True
if parsed_args.private:
kwargs['is_public'] = False
if parsed_args.force:
kwargs['force'] = True
# Wrap the call to catch exceptions in order to close files
try:
image = utils.find_resource(
image_client.images,
parsed_args.image,
)
image = image_client.find_image(parsed_args.image)
if not parsed_args.location and not parsed_args.copy_from:
if parsed_args.volume:
@ -666,9 +677,10 @@ class SetImage(command.Command):
if parsed_args.stdin:
if msvcrt:
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
# Send an open file handle to glanceclient so it
# will do a chunked transfer
kwargs["data"] = sys.stdin
if hasattr(sys.stdin, 'buffer'):
kwargs['data'] = sys.stdin.buffer
else:
kwargs["data"] = sys.stdin
else:
LOG.warning(_('Use --stdin to enable read image '
'data from standard input'))
@ -677,7 +689,7 @@ class SetImage(command.Command):
image.properties.update(kwargs['properties'])
kwargs['properties'] = image.properties
image = image_client.images.update(image.id, **kwargs)
image = image_client.update_image(image.id, **kwargs)
finally:
# Clean up open files - make sure data isn't a string
if ('data' in kwargs and hasattr(kwargs['data'], 'close') and
@ -705,16 +717,12 @@ class ShowImage(command.ShowOne):
def take_action(self, parsed_args):
image_client = self.app.client_manager.image
image = utils.find_resource(
image_client.images,
parsed_args.image,
)
image = image_client.find_image(parsed_args.image)
info = {}
info.update(image._info)
if parsed_args.human_readable:
if 'size' in info:
info['size'] = utils.format_size(info['size'])
info['properties'] = format_columns.DictColumn(
info.get('properties', {}))
return zip(*sorted(info.items()))
_formatters['size'] = HumanReadableSizeColumn
display_columns, columns = _get_columns(image)
_formatters['properties'] = format_columns.DictColumn
data = utils.get_item_properties(image, columns,
formatters=_formatters)
return (display_columns, data)

View File

@ -13,10 +13,11 @@
# under the License.
#
import copy
from unittest import mock
import uuid
from openstack.image.v1 import image
from openstackclient.tests.unit import fakes
from openstackclient.tests.unit import utils
from openstackclient.tests.unit.volume.v1 import fakes as volume_fakes
@ -111,13 +112,10 @@ class FakeImage(object):
'Alpha': 'a',
'Beta': 'b',
'Gamma': 'g'},
'status': 'status' + uuid.uuid4().hex
}
# Overwrite default attributes if there are some attributes set
image_info.update(attrs)
image = fakes.FakeResource(
info=copy.deepcopy(image_info),
loaded=True)
return image
return image.Image(**image_info)

View File

@ -17,7 +17,6 @@ import copy
from unittest import mock
from osc_lib.cli import format_columns
from osc_lib import exceptions
from openstackclient.image.v1 import image
from openstackclient.tests.unit import fakes
@ -29,9 +28,8 @@ class TestImage(image_fakes.TestImagev1):
def setUp(self):
super(TestImage, self).setUp()
# Get a shortcut to the ServerManager Mock
self.images_mock = self.app.client_manager.image.images
self.images_mock.reset_mock()
self.app.client_manager.image = mock.Mock()
self.client = self.app.client_manager.image
class TestImageCreate(TestImage):
@ -48,6 +46,7 @@ class TestImageCreate(TestImage):
'owner',
'properties',
'protected',
'size'
)
data = (
new_image.container_format,
@ -57,28 +56,24 @@ class TestImageCreate(TestImage):
new_image.min_disk,
new_image.min_ram,
new_image.name,
new_image.owner,
new_image.owner_id,
format_columns.DictColumn(new_image.properties),
new_image.protected,
new_image.is_protected,
new_image.size
)
def setUp(self):
super(TestImageCreate, self).setUp()
self.images_mock.create.return_value = self.new_image
# This is the return value for utils.find_resource()
self.images_mock.get.return_value = self.new_image
self.images_mock.update.return_value = self.new_image
self.client.create_image = mock.Mock(return_value=self.new_image)
self.client.find_image = mock.Mock(return_value=self.new_image)
self.client.update_image = mock.Mock(return_image=self.new_image)
# Get the command object to test
self.cmd = image.CreateImage(self.app, None)
def test_image_reserve_no_options(self):
mock_exception = {
'find.side_effect': exceptions.CommandError('x'),
'get.side_effect': exceptions.CommandError('x'),
}
self.images_mock.configure_mock(**mock_exception)
@mock.patch('sys.stdin', side_effect=[None])
def test_image_reserve_no_options(self, raw_input):
arglist = [
self.new_image.name,
]
@ -95,25 +90,20 @@ class TestImageCreate(TestImage):
columns, data = self.cmd.take_action(parsed_args)
# ImageManager.create(name=, **)
self.images_mock.create.assert_called_with(
self.client.create_image.assert_called_with(
name=self.new_image.name,
container_format=image.DEFAULT_CONTAINER_FORMAT,
disk_format=image.DEFAULT_DISK_FORMAT,
data=mock.ANY,
disk_format=image.DEFAULT_DISK_FORMAT
)
# Verify update() was not called, if it was show the args
self.assertEqual(self.images_mock.update.call_args_list, [])
self.assertEqual(self.client.update_image.call_args_list, [])
self.assertEqual(self.columns, columns)
self.assertItemEqual(self.data, data)
def test_image_reserve_options(self):
mock_exception = {
'find.side_effect': exceptions.CommandError('x'),
'get.side_effect': exceptions.CommandError('x'),
}
self.images_mock.configure_mock(**mock_exception)
@mock.patch('sys.stdin', side_effect=[None])
def test_image_reserve_options(self, raw_input):
arglist = [
'--container-format', 'ovf',
'--disk-format', 'ami',
@ -144,20 +134,19 @@ class TestImageCreate(TestImage):
columns, data = self.cmd.take_action(parsed_args)
# ImageManager.create(name=, **)
self.images_mock.create.assert_called_with(
self.client.create_image.assert_called_with(
name=self.new_image.name,
container_format='ovf',
disk_format='ami',
min_disk=10,
min_ram=4,
protected=True,
is_protected=True,
is_public=False,
owner='q',
data=mock.ANY,
owner_id='q',
)
# Verify update() was not called, if it was show the args
self.assertEqual(self.images_mock.update.call_args_list, [])
self.assertEqual(self.client.update_image.call_args_list, [])
self.assertEqual(self.columns, columns)
self.assertItemEqual(self.data, data)
@ -167,11 +156,6 @@ class TestImageCreate(TestImage):
mock_file = mock.Mock(name='File')
mock_open.return_value = mock_file
mock_open.read.return_value = self.data
mock_exception = {
'find.side_effect': exceptions.CommandError('x'),
'get.side_effect': exceptions.CommandError('x'),
}
self.images_mock.configure_mock(**mock_exception)
arglist = [
'--file', 'filer',
@ -203,15 +187,12 @@ class TestImageCreate(TestImage):
# Ensure the input file is closed
mock_file.close.assert_called_with()
# ImageManager.get(name) not to be called since update action exists
self.images_mock.get.assert_not_called()
# ImageManager.create(name=, **)
self.images_mock.create.assert_called_with(
self.client.create_image.assert_called_with(
name=self.new_image.name,
container_format=image.DEFAULT_CONTAINER_FORMAT,
disk_format=image.DEFAULT_DISK_FORMAT,
protected=False,
is_protected=False,
is_public=True,
properties={
'Alpha': '1',
@ -221,7 +202,7 @@ class TestImageCreate(TestImage):
)
# Verify update() was not called, if it was show the args
self.assertEqual(self.images_mock.update.call_args_list, [])
self.assertEqual(self.client.update_image.call_args_list, [])
self.assertEqual(self.columns, columns)
self.assertItemEqual(self.data, data)
@ -235,8 +216,8 @@ class TestImageDelete(TestImage):
super(TestImageDelete, self).setUp()
# This is the return value for utils.find_resource()
self.images_mock.get.return_value = self._image
self.images_mock.delete.return_value = None
self.client.find_image = mock.Mock(return_value=self._image)
self.client.delete_image = mock.Mock(return_value=None)
# Get the command object to test
self.cmd = image.DeleteImage(self.app, None)
@ -252,7 +233,7 @@ class TestImageDelete(TestImage):
result = self.cmd.take_action(parsed_args)
self.images_mock.delete.assert_called_with(self._image.id)
self.client.delete_image.assert_called_with(self._image.id)
self.assertIsNone(result)
@ -269,7 +250,7 @@ class TestImageList(TestImage):
(
_image.id,
_image.name,
'',
_image.status
),
)
@ -277,13 +258,13 @@ class TestImageList(TestImage):
info = {
'id': _image.id,
'name': _image.name,
'owner': _image.owner,
'owner': _image.owner_id,
'container_format': _image.container_format,
'disk_format': _image.disk_format,
'min_disk': _image.min_disk,
'min_ram': _image.min_ram,
'is_public': _image.is_public,
'protected': _image.protected,
'protected': _image.is_protected,
'properties': _image.properties,
}
image_info = copy.deepcopy(info)
@ -291,11 +272,10 @@ class TestImageList(TestImage):
def setUp(self):
super(TestImageList, self).setUp()
self.api_mock = mock.Mock()
self.api_mock.image_list.side_effect = [
[self.image_info], [],
self.client.images = mock.Mock()
self.client.images.side_effect = [
[self._image], [],
]
self.app.client_manager.image.api = self.api_mock
# Get the command object to test
self.cmd = image.ListImage(self.app, None)
@ -313,10 +293,7 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
marker=self._image.id,
)
self.client.images.assert_called_with()
self.assertEqual(self.columns, columns)
self.assertEqual(self.datalist, tuple(data))
@ -336,10 +313,8 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
public=True,
marker=self._image.id,
self.client.images.assert_called_with(
is_public=True,
)
self.assertEqual(self.columns, columns)
@ -360,10 +335,8 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
private=True,
marker=self._image.id,
self.client.images.assert_called_with(
is_private=True,
)
self.assertEqual(self.columns, columns)
@ -382,10 +355,7 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
marker=self._image.id,
)
self.client.images.assert_called_with()
collist = (
'ID',
@ -405,14 +375,14 @@ class TestImageList(TestImage):
datalist = ((
self._image.id,
self._image.name,
'',
'',
'',
'',
'',
image.VisibilityColumn(True),
False,
self._image.owner,
self._image.disk_format,
self._image.container_format,
self._image.size,
self._image.checksum,
self._image.status,
image.VisibilityColumn(self._image.is_public),
self._image.is_protected,
self._image.owner_id,
format_columns.DictColumn(
{'Alpha': 'a', 'Beta': 'b', 'Gamma': 'g'}),
), )
@ -436,12 +406,9 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
marker=self._image.id,
)
self.client.images.assert_called_with()
sf_mock.assert_called_with(
[self.image_info],
[self._image],
attr='a',
value='1',
property_field='properties',
@ -453,7 +420,7 @@ class TestImageList(TestImage):
@mock.patch('osc_lib.utils.sort_items')
def test_image_list_sort_option(self, si_mock):
si_mock.side_effect = [
[self.image_info], [],
[self._image], [],
]
arglist = ['--sort', 'name:asc']
@ -464,12 +431,9 @@ class TestImageList(TestImage):
# returns a tuple containing the column names and an iterable
# containing the data to be listed.
columns, data = self.cmd.take_action(parsed_args)
self.api_mock.image_list.assert_called_with(
detailed=True,
marker=self._image.id,
)
self.client.images.assert_called_with()
si_mock.assert_called_with(
[self.image_info],
[self._image],
'name:asc'
)
@ -485,8 +449,8 @@ class TestImageSet(TestImage):
super(TestImageSet, self).setUp()
# This is the return value for utils.find_resource()
self.images_mock.get.return_value = self._image
self.images_mock.update.return_value = self._image
self.client.find_image = mock.Mock(return_value=self._image)
self.client.update_image = mock.Mock(return_value=self._image)
# Get the command object to test
self.cmd = image.SetImage(self.app, None)
@ -502,8 +466,7 @@ class TestImageSet(TestImage):
result = self.cmd.take_action(parsed_args)
self.images_mock.update.assert_called_with(self._image.id,
**{})
self.client.update_image.assert_called_with(self._image.id, **{})
self.assertIsNone(result)
def test_image_set_options(self):
@ -541,7 +504,7 @@ class TestImageSet(TestImage):
'size': 35165824
}
# ImageManager.update(image, **kwargs)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
**kwargs
)
@ -565,11 +528,11 @@ class TestImageSet(TestImage):
result = self.cmd.take_action(parsed_args)
kwargs = {
'protected': True,
'is_protected': True,
'is_public': False,
}
# ImageManager.update(image, **kwargs)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
**kwargs
)
@ -593,11 +556,11 @@ class TestImageSet(TestImage):
result = self.cmd.take_action(parsed_args)
kwargs = {
'protected': False,
'is_protected': False,
'is_public': True,
}
# ImageManager.update(image, **kwargs)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
**kwargs
)
@ -625,7 +588,7 @@ class TestImageSet(TestImage):
},
}
# ImageManager.update(image, **kwargs)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
**kwargs
)
@ -683,7 +646,7 @@ class TestImageSet(TestImage):
'',
)
# ImageManager.update(image_id, remove_props=, **)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
name='updated_image',
volume='volly',
@ -710,7 +673,7 @@ class TestImageSet(TestImage):
'min_ram': 0,
}
# ImageManager.update(image, **kwargs)
self.images_mock.update.assert_called_with(
self.client.update_image.assert_called_with(
self._image.id,
**kwargs
)
@ -742,16 +705,16 @@ class TestImageShow(TestImage):
_image.min_disk,
_image.min_ram,
_image.name,
_image.owner,
_image.owner_id,
format_columns.DictColumn(_image.properties),
_image.protected,
_image.is_protected,
_image.size,
)
def setUp(self):
super(TestImageShow, self).setUp()
self.images_mock.get.return_value = self._image
self.client.find_image = mock.Mock(return_value=self._image)
# Get the command object to test
self.cmd = image.ShowImage(self.app, None)
@ -769,7 +732,7 @@ class TestImageShow(TestImage):
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.images_mock.get.assert_called_with(
self.client.find_image.assert_called_with(
self._image.id,
)
@ -791,9 +754,9 @@ class TestImageShow(TestImage):
# returns a two-part tuple with a tuple of column names and a tuple of
# data to be shown.
columns, data = self.cmd.take_action(parsed_args)
self.images_mock.get.assert_called_with(
self.client.find_image.assert_called_with(
self._image.id,
)
size_index = columns.index('size')
self.assertEqual(data[size_index], '2K')
self.assertEqual(data[size_index].human_readable(), '2K')

View File

@ -0,0 +1,4 @@
---
features:
- |
Complete switch from glanceclient to the SDK for image service.

View File

@ -10,7 +10,6 @@ openstacksdk>=0.36.0 # Apache-2.0
osc-lib>=2.0.0 # Apache-2.0
oslo.i18n>=3.15.3 # Apache-2.0
oslo.utils>=3.33.0 # Apache-2.0
python-glanceclient>=2.8.0 # Apache-2.0
python-keystoneclient>=3.22.0 # Apache-2.0
python-novaclient>=15.1.0 # Apache-2.0
python-cinderclient>=3.3.0 # Apache-2.0