Merge "Add support for QoS type and specs (2.94)"

This commit is contained in:
Zuul
2026-02-26 11:54:56 +00:00
committed by Gerrit Code Review
14 changed files with 1536 additions and 1 deletions

View File

@@ -221,3 +221,10 @@ resource locks
.. autoprogram-cliff:: openstack.share.v2
:command: share lock *
=========
qos types
=========
.. autoprogram-cliff:: openstack.share.v2
:command: share qos type *

View File

@@ -27,7 +27,7 @@ from manilaclient import utils
LOG = logging.getLogger(__name__)
MAX_VERSION = '2.93'
MAX_VERSION = '2.94'
MIN_VERSION = '2.0'
DEPRECATED_VERSION = '1.0'
_VERSIONED_METHOD_MAP = {}

View File

@@ -113,6 +113,14 @@ BACKUP_SORT_KEY_VALUES = (
'project_id',
)
QOS_TYPE_SORT_KEY_VALUES = (
'id',
'name',
'created_at',
'updated_at',
)
TASK_STATE_MIGRATION_SUCCESS = 'migration_success'
TASK_STATE_MIGRATION_ERROR = 'migration_error'
TASK_STATE_MIGRATION_CANCELLED = 'migration_cancelled'
@@ -172,3 +180,4 @@ REPLICA_GRADUATION_VERSION = '2.56'
REPLICA_PRE_GRADUATION_VERSION = '2.55'
SHARE_TRANSFER_VERSION = '2.77'
RESOURCE_LOCK_VERSION = '2.81'
QOS_TYPE_VERSION = '2.94'

View File

@@ -0,0 +1,390 @@
# Copyright (c) 2025 Cloudification GmbH.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import logging
from osc_lib.cli import parseractions
from osc_lib.command import command
from osc_lib import exceptions
from osc_lib import utils as oscutils
from manilaclient.common._i18n import _
from manilaclient.common.apiclient import utils as apiutils
from manilaclient.common import constants
from manilaclient.osc import utils
LOG = logging.getLogger(__name__)
ATTRIBUTES = [
'id',
'name',
'description',
'specs',
'created_at',
'updated_at',
]
def format_qos_type(qos_type, formatter='table'):
specs = qos_type.specs
if formatter == 'table':
qos_type._info.update({'specs': utils.format_properties(specs)})
else:
qos_type._info.update({'specs': specs})
return qos_type
class CreateQosType(command.ShowOne):
"""Create new qos type."""
_description = _("Create new qos type")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'name',
metavar="<name>",
help=_('QoS type name. This must be unique.'),
)
parser.add_argument(
"--description",
metavar="<description>",
default=None,
help=_("QoS type description."),
)
parser.add_argument(
"--spec",
type=str,
metavar='<key=value>',
action='append',
default=None,
help=_(
"Spec key and value of QoS type that will be"
" used for QoS type creation. OPTIONAL: Default=None."
" Example: --spec qos_type='fixed' --spec peak_iops=300."
),
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
kwargs = {'name': parsed_args.name}
if parsed_args.description:
kwargs['description'] = parsed_args.description
if parsed_args.spec:
specs = utils.extract_properties(parsed_args.spec)
kwargs['specs'] = specs
qos_type = share_client.qos_types.create(**kwargs)
formatted_type = format_qos_type(qos_type, parsed_args.formatter)
return (
ATTRIBUTES,
oscutils.get_dict_properties(formatted_type._info, ATTRIBUTES),
)
class DeleteQosType(command.Command):
"""Delete a qos type."""
_description = _("Delete a qos type")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'qos_types',
metavar="<qos_types>",
nargs="+",
help=_("Name or ID of the qos type(s) to delete"),
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
result = 0
for qos_type in parsed_args.qos_types:
try:
qos_type_obj = apiutils.find_resource(
share_client.qos_types, qos_type
)
share_client.qos_types.delete(qos_type_obj)
except Exception as e:
result += 1
LOG.error(
_(
"Failed to delete qos type with "
"name or ID '%(qos_type)s': %(e)s"
),
{'qos_type': qos_type, 'e': e},
)
if result > 0:
total = len(parsed_args.qos_types)
msg = _("%(result)s of %(total)s qos types failed to delete.") % {
'result': result,
'total': total,
}
raise exceptions.CommandError(msg)
class SetQosType(command.Command):
"""Set qos type description or specs."""
_description = _("Set qos type description or specs")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'qos_type',
metavar="<qos_type>",
help=_("Name or ID of the qos type to modify"),
)
parser.add_argument(
"--description",
metavar="<description>",
default=None,
help=_("New description of qos type."),
)
parser.add_argument(
"--spec",
type=str,
metavar='<key=value>',
action='append',
default=None,
help=_(
"Spec key and value of qos type that will be "
"used for QoS type. OPTIONAL: Default=None. For "
"example --spec qos_type='fixed' --spec peak_iops=300"
),
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
qos_type = apiutils.find_resource(
share_client.qos_types, parsed_args.qos_type
)
kwargs = {}
if parsed_args.description:
kwargs['description'] = parsed_args.description
if kwargs:
try:
qos_type.update(**kwargs)
except Exception as e:
raise exceptions.CommandError(
_("Failed to set qos type description: %s") % e
)
# These are dict of key=value to be added as qos type specs.
if parsed_args.spec:
specs = utils.extract_properties(parsed_args.spec)
try:
qos_type.set_keys(specs)
except Exception as e:
raise exceptions.CommandError(
_("Failed to set qos type spec: %s") % e
)
class UnsetQosType(command.Command):
"""Unset qos type specs."""
_description = _("Unset qos type specs")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'qos_type',
metavar="<qos_type>",
help=_("Name or ID of the qos type to modify"),
)
parser.add_argument(
"--description",
action='store_true',
help=_("Unset qos type description."),
)
parser.add_argument(
'--spec',
metavar='<key>',
action='append',
help=_('Remove specified spec from this qos type'),
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
qos_type = apiutils.find_resource(
share_client.qos_types, parsed_args.qos_type
)
kwargs = {}
if parsed_args.description:
kwargs['description'] = None
if kwargs:
try:
qos_type.update(**kwargs)
except Exception as e:
raise exceptions.CommandError(
_("Failed to unset qos type description: %s") % e
)
# These are list of keys to be deleted from qos type specs.
if parsed_args.spec:
try:
qos_type.unset_keys(parsed_args.spec)
except Exception as e:
raise exceptions.CommandError(
_("Failed to remove qos type spec: %s") % e
)
class ListQosType(command.Lister):
"""List Qos Types."""
_description = _("List qos types")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'--name',
metavar="<name>",
default=None,
help=_('Filter results by name. Default=None.'),
)
parser.add_argument(
'--description',
metavar="<description>",
default=None,
help=_("Filter results by description. Default=None."),
)
parser.add_argument(
"--name~",
metavar="<name~>",
default=None,
help=_("Filter results matching a qos name pattern. "),
)
parser.add_argument(
'--description~',
metavar="<description~>",
default=None,
help=_("Filter results matching a qos description pattern."),
)
parser.add_argument(
"--limit",
metavar="<num-qos-types>",
type=int,
default=None,
action=parseractions.NonNegativeAction,
help=_("Limit the number of qos types returned. Default=None."),
)
parser.add_argument(
'--offset',
metavar="<offset>",
default=None,
help=_('Start position of qos type records listing.'),
)
parser.add_argument(
'--sort-key',
'--sort_key',
metavar='<sort_key>',
type=str,
default=None,
help=_(
'Key to be sorted with, available keys are '
'%(keys)s. Default=None.'
)
% {'keys': constants.QOS_TYPE_SORT_KEY_VALUES},
)
parser.add_argument(
'--sort-dir',
'--sort_dir',
metavar='<sort_dir>',
type=str,
default=None,
help=_(
'Sort direction, available values are '
'%(dirs)s. OPTIONAL: Default=None.'
)
% {'dirs': constants.SORT_DIR_VALUES},
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
search_opts = {
'name': parsed_args.name,
'description': parsed_args.description,
'limit': parsed_args.limit,
'offset': parsed_args.offset,
}
search_opts['name~'] = getattr(parsed_args, 'name~')
search_opts['description~'] = getattr(parsed_args, 'description~')
qos_types = share_client.qos_types.list(
search_opts=search_opts,
sort_key=parsed_args.sort_key,
sort_dir=parsed_args.sort_dir,
)
formatted_types = []
for qos_type in qos_types:
formatted_types.append(
format_qos_type(qos_type, parsed_args.formatter)
)
values = (
oscutils.get_dict_properties(s._info, ATTRIBUTES)
for s in formatted_types
)
columns = utils.format_column_headers(ATTRIBUTES)
return (columns, values)
class ShowQosType(command.ShowOne):
"""Show a qos type."""
_description = _("Display qos type details")
def get_parser(self, prog_name):
parser = super().get_parser(prog_name)
parser.add_argument(
'qos_type',
metavar="<qos_type>",
help=_("Qos type to display (name or ID)"),
)
return parser
def take_action(self, parsed_args):
share_client = self.app.client_manager.share
qos_type = apiutils.find_resource(
share_client.qos_types, parsed_args.qos_type
)
formatted_type = format_qos_type(qos_type, parsed_args.formatter)
return (
ATTRIBUTES,
oscutils.get_dict_properties(formatted_type._info, ATTRIBUTES),
)

View File

@@ -563,3 +563,36 @@ class OSCClientTestBase(base.ClientTestBase):
)
return backup_object
def create_qos_type(
self,
name=None,
description=None,
specs=None,
add_cleanup=True,
client=None,
formatter=None,
):
name = name or data_utils.rand_name('autotest_qos_type_name')
specs = specs or {}
cmd = f'create {name} '
if description:
cmd += f' --description {description}'
if specs:
q_specs = ''
for key, value in specs.items():
q_specs += f' --spec {key}={value}'
cmd += q_specs
if formatter == 'json':
cmd = f'share qos type {cmd} -f {formatter} '
qos_type = json.loads(self.openstack(cmd, client=client))
else:
qos_type = self.dict_result('share qos type', cmd, client=client)
if add_cleanup:
self.addCleanup(
self.openstack, f'share qos type delete {qos_type["id"]}'
)
return qos_type

View File

@@ -0,0 +1,146 @@
# Copyright (c) 2025 Cloudification GmbH.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import json
from manilaclient.tests.functional.osc import base
class QosTypesCLITest(base.OSCClientTestBase):
def test_qos_type_create(self):
name = 'test_qos_type'
description = 'Description'
qos_type = self.create_qos_type(name=name, description=description)
self.assertEqual(name, qos_type["name"])
self.assertEqual(description, qos_type["description"])
qos_types_list = self.listing_result('share qos type', 'list')
self.assertIn(qos_type["id"], [item['ID'] for item in qos_types_list])
def test_qos_type_create_specs(self):
name = 'test_qos_type2'
qos_type = self.create_qos_type(
name=name,
specs={"foo": "bar", "manila": "gazpacho"},
formatter='json',
)
specs = qos_type["specs"]
self.assertEqual("bar", specs["foo"])
self.assertEqual("gazpacho", specs["manila"])
def test_qos_type_create_specs_using_command(self):
self.openstack(
'share qos type create test_qos_type2_cli '
'--description test_cli --spec foo1=bar1 --spec foo2=bar2'
)
self.addCleanup(
self.openstack, 'share qos type delete test_qos_type2_cli'
)
qos_type = json.loads(
self.openstack('share qos type show test_qos_type2_cli -f json')
)
self.assertEqual('test_cli', qos_type["description"])
self.assertEqual('bar1', qos_type["specs"]["foo1"])
self.assertEqual('bar2', qos_type["specs"]["foo2"])
def test_qos_type_delete(self):
qos_type_1 = self.create_qos_type(
name='test_qos_type3', add_cleanup=False
)
qos_type_2 = self.create_qos_type(
name='test_qos_type4', add_cleanup=False
)
self.openstack(
f'share qos type delete {qos_type_1["id"]} {qos_type_2["id"]}'
)
self.check_object_deleted('share qos type', qos_type_1["id"])
self.check_object_deleted('share qos type', qos_type_2["id"])
def test_qos_type_set(self):
qos_type = self.create_qos_type(name='test_qos_type5')
self.openstack(
f'share qos type set {qos_type["id"]} --description Description'
' --spec foo=bar2'
)
qos_type = json.loads(
self.openstack(f'share qos type show {qos_type["id"]} -f json')
)
self.assertEqual('Description', qos_type["description"])
self.assertEqual('bar2', qos_type["specs"]["foo"])
def test_qos_type_unset(self):
qos_type = self.create_qos_type(
name='test_qos_type6', specs={'foo': 'bar', 'foo1': 'bar1'}
)
self.openstack(
f'share qos type unset {qos_type["id"]} --spec foo --spec foo1'
)
qos_type = json.loads(
self.openstack(f'share qos type show {qos_type["id"]} -f json')
)
self.assertNotIn('foo', qos_type["specs"])
self.assertNotIn('foo1', qos_type["specs"])
def test_qos_type_list(self):
qos_type_1 = self.create_qos_type(name='test_qos_type7')
qos_type_2 = self.create_qos_type(
name='test_qos_type8', specs={'foo': 'bar'}
)
types_list = self.listing_result(
'share qos type', 'list', client=self.admin_client
)
self.assertTableStruct(
types_list,
[
'ID',
'Name',
'Description',
'Specs',
],
)
id_list = [item['ID'] for item in types_list]
self.assertIn(qos_type_1['id'], id_list)
self.assertIn(qos_type_2['id'], id_list)
types_list = self.listing_result('share qos type', 'list')
id_list = [item['ID'] for item in types_list]
self.assertIn(qos_type_1['id'], id_list)
self.assertIn(qos_type_2['id'], id_list)
def test_qos_type_show(self):
qos_type = self.create_qos_type(
name='test_qos_type10', specs={'foo': 'bar'}
)
result = json.loads(
self.openstack(f'share qos type show {qos_type["id"]} -f json')
)
self.assertEqual(qos_type["name"], result["name"])
self.assertEqual('bar', result["specs"]["foo"])

View File

@@ -64,6 +64,7 @@ class FakeShareClient:
self.share_group_type_access = mock.Mock()
self.share_servers = mock.Mock()
self.resource_locks = mock.Mock()
self.qos_types = mock.Mock()
class TestShare(osc_utils.TestCommand):
@@ -1626,3 +1627,77 @@ class FakeShareBackup:
for n in range(0, count):
share_backups.append(FakeShareBackup.create_one_backup(attrs))
return share_backups
class FakeQosType:
"""Fake one or more qos types"""
@staticmethod
def create_one_qostype(attrs=None, methods=None):
"""Create a fake qos type
:param Dictionary attrs:
A dictionary with all attributes
:return:
A FakeResource object, with project_id, resource and so on
"""
attrs = attrs or {}
methods = methods or {}
qos_type_info = {
"id": 'qos-type-id-' + uuid.uuid4().hex,
"name": 'qos-type-name-' + uuid.uuid4().hex,
"description": 'qos-type-description-' + uuid.uuid4().hex,
"specs": {
"expected_iops": "2000",
"peak_iops": "5000",
},
"created_at": 'time-' + uuid.uuid4().hex,
"updated_at": 'time-' + uuid.uuid4().hex,
}
qos_type_info.update(attrs)
qos_type = osc_fakes.FakeResource(
info=copy.deepcopy(qos_type_info), methods=methods, loaded=True
)
return qos_type
@staticmethod
def create_qos_types(attrs=None, count=2):
"""Create multiple fake qos types.
:param Dictionary attrs:
A dictionary with all attributes
:param Integer count:
The number of qos types to be faked
:return:
A list of FakeResource objects
"""
qos_types = []
for n in range(0, count):
qos_types.append(FakeQosType.create_one_qostype(attrs))
return qos_types
@staticmethod
def get_qos_types(qos_types=None, count=2):
"""Get an iterable MagicMock object with a list of faked types.
If types list is provided, then initialize the Mock object with the
list. Otherwise create one.
:param List types:
A list of FakeResource objects faking types
:param Integer count:
The number of types to be faked
:return
An iterable Mock object with side_effect set to a list of faked
types
"""
if qos_types is None:
qos_types = FakeQosType.create_qos_types(count)
return mock.Mock(side_effect=qos_types)

View File

@@ -0,0 +1,440 @@
# Copyright (c) 2025 Cloudification GmbH.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
from unittest import mock
from osc_lib import exceptions
from osc_lib import utils as oscutils
from manilaclient import api_versions
from manilaclient.common.apiclient.exceptions import BadRequest
from manilaclient.common.apiclient.exceptions import NotFound
from manilaclient.osc import utils
from manilaclient.osc.v2 import qos_types as osc_qos_types
from manilaclient.tests.unit.osc import osc_utils
from manilaclient.tests.unit.osc.v2 import fakes as manila_fakes
COLUMNS = [
'id',
'name',
'description',
'specs',
'created_at',
'updated_at',
]
class TestQosType(manila_fakes.TestShare):
def setUp(self):
super().setUp()
self.qos_types_mock = self.app.client_manager.share.qos_types
self.qos_types_mock.reset_mock()
self.app.client_manager.share.api_version = api_versions.APIVersion(
api_versions.MAX_VERSION
)
class TestQosTypeCreate(TestQosType):
def setUp(self):
super().setUp()
self.new_qos_type = manila_fakes.FakeQosType.create_one_qostype()
self.qos_types_mock.create.return_value = self.new_qos_type
# Get the command object to test
self.cmd = osc_qos_types.CreateQosType(self.app, None)
self.data = [
self.new_qos_type.id,
self.new_qos_type.name,
self.new_qos_type.description,
('expected_iops : 2000\npeak_iops : 5000'),
self.new_qos_type.created_at,
self.new_qos_type.updated_at,
]
self.raw_data = [
self.new_qos_type.id,
self.new_qos_type.name,
self.new_qos_type.description,
{
'expected_iops': '2000',
'peak_iops': '5000',
},
self.new_qos_type.created_at,
self.new_qos_type.updated_at,
]
def test_qos_type_create_required_args(self):
"""Verifies required arguments."""
arglist = [self.new_qos_type.name]
verifylist = [
('name', self.new_qos_type.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.create.assert_called_with(
name=self.new_qos_type.name,
)
self.assertCountEqual(COLUMNS, columns)
self.assertCountEqual(self.data, data)
def test_qos_type_create_json_format(self):
"""Verifies --format json."""
arglist = [self.new_qos_type.name, '-f', 'json']
verifylist = [
('name', self.new_qos_type.name),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.create.assert_called_with(
name=self.new_qos_type.name,
)
self.assertCountEqual(COLUMNS, columns)
self.assertCountEqual(self.raw_data, data)
def test_qos_type_create_missing_required_arg(self):
"""Verifies missing required arguments."""
arglist = []
verifylist = []
self.assertRaises(
osc_utils.ParserException,
self.check_parser,
self.cmd,
arglist,
verifylist,
)
def test_qos_type_create_specs(self):
arglist = [
self.new_qos_type.name,
'--spec',
'peak_iops=100',
'--spec',
'expected_iops=20',
]
verifylist = [
('name', self.new_qos_type.name),
('spec', ['peak_iops=100', 'expected_iops=20']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.create.assert_called_with(
name=self.new_qos_type.name,
specs={'peak_iops': '100', 'expected_iops': '20'},
)
self.assertCountEqual(COLUMNS, columns)
self.assertCountEqual(self.data, data)
class TestQosTypeDelete(TestQosType):
qos_types = manila_fakes.FakeQosType.create_qos_types(count=2)
def setUp(self):
super().setUp()
self.qos_types_mock.get = manila_fakes.FakeQosType.get_qos_types(
self.qos_types
)
self.qos_types_mock.delete.return_value = None
# Get the command object to test
self.cmd = osc_qos_types.DeleteQosType(self.app, None)
def test_qos_type_delete_one(self):
arglist = [self.qos_types[0].id]
verifylist = [('qos_types', [self.qos_types[0].id])]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.qos_types_mock.delete.assert_called_with(self.qos_types[0])
self.assertIsNone(result)
def test_qos_type_delete_multiple(self):
arglist = []
for t in self.qos_types:
arglist.append(t.id)
verifylist = [
('qos_types', arglist),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
calls = []
for t in self.qos_types:
calls.append(mock.call(t))
self.qos_types_mock.delete.assert_has_calls(calls)
self.assertIsNone(result)
def test_delete_qos_type_with_exception(self):
arglist = [
'non_existing_type',
]
verifylist = [
('qos_types', arglist),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.qos_types_mock.delete.side_effect = exceptions.CommandError()
self.assertRaises(
exceptions.CommandError, self.cmd.take_action, parsed_args
)
class TestQosTypeSet(TestQosType):
def setUp(self):
super().setUp()
self.qos_type = manila_fakes.FakeQosType.create_one_qostype(
methods={'set_keys': None, 'update': None}
)
self.qos_types_mock.get.return_value = self.qos_type
# Get the command object to test
self.cmd = osc_qos_types.SetQosType(self.app, None)
def test_qos_type_set_specs(self):
arglist = [
self.qos_type.id,
'--spec',
'peak_iops=100',
]
verifylist = [
('qos_type', self.qos_type.id),
('spec', ['peak_iops=100']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.qos_type.set_keys.assert_called_with({'peak_iops': '100'})
self.assertIsNone(result)
def test_qos_type_set_description(self):
arglist = [self.qos_type.id, '--description', 'new description']
verifylist = [
('qos_type', self.qos_type.id),
('description', 'new description'),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.qos_type.update.assert_called_with(description='new description')
self.assertIsNone(result)
def test_qos_type_set_specs_exception(self):
arglist = [
self.qos_type.id,
'--spec',
'peak_iops=100',
]
verifylist = [
('qos_type', self.qos_type.id),
('spec', ['peak_iops=100']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.qos_type.set_keys.side_effect = BadRequest()
self.assertRaises(
exceptions.CommandError, self.cmd.take_action, parsed_args
)
class TestQosTypeUnset(TestQosType):
def setUp(self):
super().setUp()
self.qos_type = manila_fakes.FakeQosType.create_one_qostype(
methods={'unset_keys': None, 'update': None}
)
self.qos_types_mock.get.return_value = self.qos_type
# Get the command object to test
self.cmd = osc_qos_types.UnsetQosType(self.app, None)
def test_qos_type_unset_description(self):
arglist = [self.qos_type.id, '--description']
verifylist = [
('qos_type', self.qos_type.id),
('description', True),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.qos_type.update.assert_called_with(description=None)
self.assertIsNone(result)
def test_qos_type_unset_specs(self):
arglist = [self.qos_type.id, '--spec', 'peak_iops']
verifylist = [
('qos_type', self.qos_type.id),
('spec', ['peak_iops']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
result = self.cmd.take_action(parsed_args)
self.qos_type.unset_keys.assert_called_with(['peak_iops'])
self.assertIsNone(result)
def test_qos_type_unset_exception(self):
arglist = [self.qos_type.id, '--spec', 'peak_iops']
verifylist = [
('qos_type', self.qos_type.id),
('spec', ['peak_iops']),
]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
self.qos_type.unset_keys.side_effect = NotFound()
self.assertRaises(
exceptions.CommandError, self.cmd.take_action, parsed_args
)
class TestQosTypeList(TestQosType):
qos_types = manila_fakes.FakeQosType.create_qos_types()
columns = utils.format_column_headers(COLUMNS)
def setUp(self):
super().setUp()
self.qos_types_mock.list.return_value = self.qos_types
# Get the command object to test
self.cmd = osc_qos_types.ListQosType(self.app, None)
self.values = (
oscutils.get_dict_properties(s._info, COLUMNS)
for s in self.qos_types
)
def test_qos_type_list(self):
arglist = []
verifylist = []
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.list.assert_called_once_with(
search_opts={
'offset': None,
'limit': None,
'name': None,
'description': None,
'name~': None,
'description~': None,
},
sort_key=None,
sort_dir=None,
)
self.assertEqual(self.columns, columns)
self.assertEqual(list(self.values), list(data))
def test_qos_type_list_by_name(self):
arglist = ['--name', 'fake_name']
verifylist = [('name', 'fake_name')]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.list.assert_called_with(
search_opts={
'offset': None,
'limit': None,
'name': 'fake_name',
'description': None,
'name~': None,
'description~': None,
},
sort_key=None,
sort_dir=None,
)
self.assertEqual(self.columns, columns)
self.assertEqual(list(self.values), list(data))
class TestQosTypeShow(TestQosType):
def setUp(self):
super().setUp()
self.qos_type = manila_fakes.FakeQosType.create_one_qostype()
self.qos_types_mock.get.return_value = self.qos_type
# Get the command object to test
self.cmd = osc_qos_types.ShowQosType(self.app, None)
self.data = [
self.qos_type.id,
self.qos_type.name,
self.qos_type.description,
('expected_iops : 2000\npeak_iops : 5000'),
self.qos_type.created_at,
self.qos_type.updated_at,
]
self.raw_data = [
self.qos_type.id,
self.qos_type.name,
self.qos_type.description,
{
'expected_iops': '2000',
'peak_iops': '5000',
},
self.qos_type.created_at,
self.qos_type.updated_at,
]
def test_qos_type_show(self):
arglist = [self.qos_type.id]
verifylist = [("qos_type", self.qos_type.id)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.get.assert_called_with(self.qos_type.id)
self.assertCountEqual(COLUMNS, columns)
self.assertCountEqual(self.data, data)
def test_qos_type_show_json_format(self):
arglist = [
self.qos_type.id,
'-f',
'json',
]
verifylist = [("qos_type", self.qos_type.id)]
parsed_args = self.check_parser(self.cmd, arglist, verifylist)
columns, data = self.cmd.take_action(parsed_args)
self.qos_types_mock.get.assert_called_with(self.qos_type.id)
self.assertCountEqual(COLUMNS, columns)
self.assertCountEqual(self.raw_data, data)

View File

@@ -1236,6 +1236,62 @@ class FakeHTTPClient(fakes.FakeHTTPClient):
def delete_types_1(self, **kw):
return (202, {}, None)
def get_qos_types(self, **kw):
response_body = {
'qos_types': [
{
'id': 1,
'name': 'test-type-1',
'specs': {'test1': 'test1'},
},
{
'id': 2,
'name': 'test-type-2',
'specs': {'test1': 'test1'},
},
]
}
return 200, {}, response_body
def get_qos_types_1(self, **kw):
return (
200,
{},
{
'qos_type': {
'id': 1,
'name': 'test-qos-type-1',
'specs': {'test1': 'test1'},
}
},
)
def post_qos_types(self, body, **kw):
qos_type = body['qos_type']
return (
202,
{},
{
'qos_type': {
'id': 3,
'name': 'test-qos-type-3',
'description': 'test description',
'specs': qos_type['specs'],
}
},
)
def post_qos_types_1_specs(self, body, **kw):
assert list(body) == ['specs']
return (200, {}, {'specs': {'k': 'v'}})
def delete_qos_types_1_specs_k(self, **kw):
return (204, {}, None)
def delete_qos_types_1(self, **kw):
return (202, {}, None)
def get_types_3_os_share_type_access(self, **kw):
return (
200,

View File

@@ -0,0 +1,180 @@
# Copyright (c) 2025 Cloudification GmbH.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
from unittest import mock
import ddt
from manilaclient import api_versions
from manilaclient.common import constants
from manilaclient import config
from manilaclient.tests.unit import utils
from manilaclient.tests.unit.v2 import fakes
from manilaclient.v2 import qos_types
cs = fakes.FakeClient(api_versions.APIVersion(constants.QOS_TYPE_VERSION))
CONF = config.CONF
LATEST_MICROVERSION = CONF.max_api_microversion
@ddt.ddt
class QosTypesTest(utils.TestCase):
def setUp(self):
super().setUp()
microversion = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
mock_microversion = mock.Mock(api_version=microversion)
self.manager = qos_types.QosTypeManager(api=mock_microversion)
@ddt.data(
{'expected_iops': '100'},
{'peak_iops': '500', 'expected_iops': '100'},
)
def test_init(self, specs):
info = {'specs': specs}
qos_type = qos_types.QosType(qos_types.QosTypeManager, info)
self.assertTrue(hasattr(qos_type, '_specs'))
self.assertIsInstance(qos_type._specs, dict)
def test_list_types(self):
tl = cs.qos_types.list()
cs.assert_called('GET', '/qos-types')
for t in tl:
t.api_version = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
self.assertIsInstance(t, qos_types.QosType)
self.assertTrue(callable(getattr(t, 'get_keys', '')))
self.assertEqual({'test1': 'test1'}, t.get_keys())
@ddt.data(
{'qos_type': 'fixed', 'max_iops': '100'},
{'qos_type': 'adaptive', 'peak_iops': '200'},
{'qos_type': 'fixed', 'max_iops': '100', 'expected_iops': '100'},
)
def test_create(self, specs):
specs = copy.copy(specs)
self.mock_object(
self.manager, '_create', mock.Mock(return_value="fake")
)
result = self.manager.create(
'test-qos-type-1',
specs=specs,
)
if specs is None:
specs = {}
expected_specs = dict(specs)
expected_body = {
"qos_type": {
"name": 'test-qos-type-1',
"specs": expected_specs,
}
}
self.manager._create.assert_called_once_with(
"/qos-types", expected_body, "qos_type"
)
self.assertEqual("fake", result)
def test_set_key(self):
t = cs.qos_types.get(1)
t.api_version = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
t.set_keys({'k': 'v'})
cs.assert_called('POST', '/qos-types/1/specs', {'specs': {'k': 'v'}})
def test_unset_keys(self):
t = cs.qos_types.get(1)
t.api_version = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
t.unset_keys(['k'])
cs.assert_called('DELETE', '/qos-types/1/specs/k')
def test_update(self):
self.mock_object(
self.manager, '_update', mock.Mock(return_value="fake")
)
qos_type = 1234
body = dict(description="updated test description")
expected_body = {
"qos_type": dict(description="updated test description")
}
result = self.manager.update(qos_type, **body)
self.manager._update.assert_called_once_with(
f"/qos-types/{qos_type}", expected_body, "qos_type"
)
self.assertEqual("fake", result)
def test_delete(self):
cs.qos_types.delete(1)
cs.assert_called('DELETE', '/qos-types/1')
def test_get_keys_from_resource_data(self):
version = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
manager = mock.Mock()
manager.api = mock.Mock()
manager.api.client = mock.Mock(return_value=None)
manager.api.client.get = mock.Mock(return_value=(200, {}))
valid_specs = {'test': 'test'}
qos_type = qos_types.QosType(
manager,
{'specs': valid_specs, 'name': 'test'},
loaded=True,
)
qos_type.api_version = version
actual_result = qos_type.get_keys()
self.assertEqual(actual_result, valid_specs)
self.assertEqual(manager.api.client.get.call_count, 0)
@ddt.data(
{'prefer_resource_data': True, 'resource_specs': {}},
{
'prefer_resource_data': False,
'resource_specs': {'fake': 'fake'},
},
{'prefer_resource_data': False, 'resource_specs': {}},
)
@ddt.unpack
def test_get_keys_from_api(self, prefer_resource_data, resource_specs):
version = api_versions.APIVersion(constants.QOS_TYPE_VERSION)
manager = mock.Mock()
manager.api = mock.Mock()
manager.api.client = mock.Mock(return_value=None)
valid_specs = {'test': 'test'}
manager.api.client.get = mock.Mock(
return_value=(200, {'specs': valid_specs})
)
info = {
'name': 'test',
'uuid': 'fake',
'specs': resource_specs,
}
qos_type = qos_types.QosType(manager, info, loaded=True)
qos_type.api_version = version
actual_result = qos_type.get_keys(prefer_resource_data)
self.assertEqual(actual_result, valid_specs)
self.assertEqual(manager.api.client.get.call_count, 1)

View File

@@ -22,6 +22,7 @@ from manilaclient import exceptions
from manilaclient.v2 import availability_zones
from manilaclient.v2 import limits
from manilaclient.v2 import messages
from manilaclient.v2 import qos_types
from manilaclient.v2 import quota_classes
from manilaclient.v2 import quotas
from manilaclient.v2 import resource_locks
@@ -231,6 +232,7 @@ class Client:
self.limits = limits.LimitsManager(self)
self.transfers = share_transfers.ShareTransferManager(self)
self.messages = messages.MessageManager(self)
self.qos_types = qos_types.QosTypeManager(self)
self.services = services.ServiceManager(self)
self.security_services = security_services.SecurityServiceManager(self)
self.share_networks = share_networks.ShareNetworkManager(self)

View File

@@ -0,0 +1,185 @@
# Copyright (c) 2025 Cloudification GmbH.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Qos Type interface.
"""
from manilaclient import api_versions
from manilaclient import base
from manilaclient.common import constants
class QosType(base.Resource):
"""A Qos Type represents Quality of service of Manila resource."""
def __init__(self, manager, info, loaded=False):
super().__init__(manager, info, loaded)
self._specs = info.get('specs', {})
def __repr__(self):
return f"<QosType: {self.name}>"
def get_keys(self, prefer_resource_data=True):
"""Get specs from a qos type.
:param prefer_resource_data: By default specs are retrieved from
resource data, but user can force this method to make API call.
:return: dict with specs
"""
specs = getattr(self, 'specs', None)
if prefer_resource_data and specs:
return specs
qos_type_id = base.getid(self)
_resp, body = self.manager.api.client.get(
f"/qos-types/{qos_type_id}/specs"
)
self.specs = body["specs"]
return body["specs"]
def set_keys(self, metadata):
"""Set specs on a qos type.
:param metadata: A dict of key/value pairs to be set
"""
body = {'specs': metadata}
qos_type_id = base.getid(self)
return self.manager._create(
f"/qos-types/{qos_type_id}/specs",
body,
"specs",
return_raw=True,
)
def unset_keys(self, keys):
"""Unset specs on a qos type.
:param keys: A list of keys to be unset
"""
qos_type_id = base.getid(self)
for k in keys:
self.manager._delete(f"/qos-types/{qos_type_id}/specs/{k}")
def update(self, **kwargs):
"""Update this qos type."""
return self.manager.update(self, **kwargs)
def delete(self):
"""Delete this qos type."""
return self.manager.delete(self)
class QosTypeManager(base.ManagerWithFind):
"""Manage :class:`QosType` resources."""
resource_class = QosType
@api_versions.wraps(constants.QOS_TYPE_VERSION)
def list(self, search_opts=None, sort_key=None, sort_dir=None):
"""Get a list of all qos types.
:param search_opts: Search options to filter out qos types.
:param sort_key: Key to be sorted.
:param sort_dir: Sort direction, should be 'desc' or 'asc'.
:rtype: list of :class:`QosType`.
"""
search_opts = search_opts or {}
if sort_key is not None:
if sort_key in constants.QOS_TYPE_SORT_KEY_VALUES:
search_opts['sort_key'] = sort_key
else:
raise ValueError(
'sort_key must be one of the following: {}.'.format(
', '.join(constants.QOS_TYPE_SORT_KEY_VALUES)
)
)
if sort_dir is not None:
if sort_dir in constants.SORT_DIR_VALUES:
search_opts['sort_dir'] = sort_dir
else:
raise ValueError(
'sort_dir must be one of the following: {}.'.format(
', '.join(constants.SORT_DIR_VALUES)
)
)
query_string = self._build_query_string(search_opts)
return self._list(f"/qos-types{query_string}", "qos_types")
@api_versions.wraps(constants.QOS_TYPE_VERSION)
def get(self, qos_type):
"""Get a specific qos type.
:param qos_type: The ID of the :class:`QosType` to get.
:rtype: :class:`QosType`
"""
qos_type_id = base.getid(qos_type)
return self._get(f"/qos-types/{qos_type_id}", "qos_type")
@api_versions.wraps(constants.QOS_TYPE_VERSION)
def delete(self, qos_type):
"""Delete a specific qos_type.
:param qos_type: The name or ID of the :class:`QosType` to get.
"""
qos_type_id = base.getid(qos_type)
self._delete(f"/qos-types/{qos_type_id}")
@api_versions.wraps(constants.QOS_TYPE_VERSION)
def create(self, name, description=None, specs=None):
"""Create a qos type.
:param name: Descriptive name of the qos type
:param specs: Specs of the qos type
:rtype: :class:`QosType`
"""
if specs is None:
specs = {}
body = {
"qos_type": {
"name": name,
"specs": specs,
}
}
if description:
body["qos_type"]["description"] = description
return self._create("/qos-types", body, "qos_type")
@api_versions.wraps(constants.QOS_TYPE_VERSION)
def update(self, qos_type, **kwargs):
"""Update the description for a qos type.
:param qos_type: the ID of the :class: `QosType` to update.
:param description: Description of the qos type.
:rtype: :class:`QosType`
"""
if not kwargs:
return
body = {
'qos_type': kwargs,
}
qos_type_id = base.getid(qos_type)
return self._update(f"/qos-types/{qos_type_id}", body, "qos_type")

View File

@@ -184,6 +184,12 @@ share_lock_show = "manilaclient.osc.v2.resource_locks:ShowResourceLock"
share_lock_set = " manilaclient.osc.v2.resource_locks:SetResourceLock"
share_lock_unset = "manilaclient.osc.v2.resource_locks:UnsetResourceLock"
share_lock_delete = "manilaclient.osc.v2.resource_locks:DeleteResourceLock"
share_qos_type_create = "manilaclient.osc.v2.qos_types:CreateQosType"
share_qos_type_list = "manilaclient.osc.v2.qos_types:ListQosType"
share_qos_type_show = "manilaclient.osc.v2.qos_types:ShowQosType"
share_qos_type_set = "manilaclient.osc.v2.qos_types:SetQosType"
share_qos_type_unset = "manilaclient.osc.v2.qos_types:UnsetQosType"
share_qos_type_delete = "manilaclient.osc.v2.qos_types:DeleteQosType"
[tool.setuptools]
packages = [

View File

@@ -0,0 +1,6 @@
---
features:
- |
Added support for QoS types and specs via the Python SDK and the
``openstack share qos type`` CLI commands (create, delete, list, show,
set, unset). Available from microversion 2.94 and above.