From e73a0bc84bb8b33e2fdcb4dcdf642584a3fb4221 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Thu, 30 Jan 2025 11:06:53 +0000 Subject: [PATCH] api: Add ability to filter flavors by name Change-Id: I0d51d29339d1380b93ccb1501e33891082f930ec Signed-off-by: Stephen Finucane --- api-ref/source/flavors.inc | 1 + api-ref/source/parameters.yaml | 12 +++ .../versions/v21-version-get-resp.json | 2 +- .../versions/versions-get-resp.json | 2 +- nova/api/openstack/api_version_request.py | 3 +- nova/api/openstack/compute/flavors.py | 9 ++- nova/api/openstack/compute/schemas/flavors.py | 8 +- nova/objects/flavor.py | 17 +++- .../api/openstack/compute/test_flavors.py | 78 ++++++++++++++++++- nova/tests/unit/api/openstack/fakes.py | 6 ++ nova/tests/unit/objects/test_flavor.py | 10 ++- .../flavor-name-search-4133a0788bd1c37f.yaml | 7 ++ 12 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 releasenotes/notes/flavor-name-search-4133a0788bd1c37f.yaml diff --git a/api-ref/source/flavors.inc b/api-ref/source/flavors.inc index b7cd8edfe39a..bea4d4ca3e12 100644 --- a/api-ref/source/flavors.inc +++ b/api-ref/source/flavors.inc @@ -33,6 +33,7 @@ Request - minDisk: minDisk - minRam: minRam - is_public: flavor_is_public_query + - name: flavor_name_query Response -------- diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index a49b8ad5e39e..edbf3c2cf3dd 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -717,6 +717,18 @@ flavor_is_public_query: ``f``, ``false``, ``off``, ``n`` and ``no`` are treated as ``False`` (they are case-insensitive). If the value is ``None`` (case-insensitive) both public and private flavors will be listed in a single request. +flavor_name_query: + description: | + Filters the response by a flavor name, as a string. You can use regular expressions + in the query. For example, the ``?name=bob`` regular expression returns both bob + and bobb. If you must match on only bob, you can use a regular expression that + matches the syntax of the underlying database server that is implemented for Compute, + such as MySQL or PostgreSQL. + format: regexp + in: query + required: false + type: string + min_version: 2.102 flavor_query: description: | Filters the response by a flavor, as a UUID. A flavor is a combination of memory, diff --git a/doc/api_samples/versions/v21-version-get-resp.json b/doc/api_samples/versions/v21-version-get-resp.json index 77e8a6b2b6e4..c065f1ecd669 100644 --- a/doc/api_samples/versions/v21-version-get-resp.json +++ b/doc/api_samples/versions/v21-version-get-resp.json @@ -19,7 +19,7 @@ } ], "status": "CURRENT", - "version": "2.101", + "version": "2.102", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/doc/api_samples/versions/versions-get-resp.json b/doc/api_samples/versions/versions-get-resp.json index 19d85f22f9bb..e66f5c3f6599 100644 --- a/doc/api_samples/versions/versions-get-resp.json +++ b/doc/api_samples/versions/versions-get-resp.json @@ -22,7 +22,7 @@ } ], "status": "CURRENT", - "version": "2.101", + "version": "2.102", "min_version": "2.1", "updated": "2013-07-23T11:33:21Z" } diff --git a/nova/api/openstack/api_version_request.py b/nova/api/openstack/api_version_request.py index 827c5c1253ff..f82b52c1686b 100644 --- a/nova/api/openstack/api_version_request.py +++ b/nova/api/openstack/api_version_request.py @@ -281,6 +281,7 @@ REST_API_VERSION_HISTORY = """REST API Version History: * 2.101 - Attaching a volume via ``POST /servers/{server_id}/os-volume_attachments`` returns HTTP 202 Accepted instead of HTTP 200 and a volumeAttachment response. + * 2.102 - Add support for filtering flavors by name. """ # The minimum and maximum versions of the API supported @@ -289,7 +290,7 @@ REST_API_VERSION_HISTORY = """REST API Version History: # Note(cyeoh): This only applies for the v2.1 API once microversions # support is fully merged. It does not affect the V2 API. _MIN_API_VERSION = '2.1' -_MAX_API_VERSION = '2.101' +_MAX_API_VERSION = '2.102' DEFAULT_API_VERSION = _MIN_API_VERSION # Almost all proxy APIs which are related to network, images and baremetal diff --git a/nova/api/openstack/compute/flavors.py b/nova/api/openstack/compute/flavors.py index b96a32ca727c..dc513b9820c4 100644 --- a/nova/api/openstack/compute/flavors.py +++ b/nova/api/openstack/compute/flavors.py @@ -137,7 +137,8 @@ class FlavorsController(wsgi.Controller): @wsgi.expected_errors(400) @validation.query_schema(schema.index_query, '2.0', '2.74') - @validation.query_schema(schema.index_query_275, '2.75') + @validation.query_schema(schema.index_query_v275, '2.75', '2.101') + @validation.query_schema(schema.index_query_v2102, '2.102') @validation.response_body_schema(schema.index_response, '2.0', '2.54') @validation.response_body_schema(schema.index_response_v255, '2.55') def index(self, req): @@ -147,7 +148,8 @@ class FlavorsController(wsgi.Controller): @wsgi.expected_errors(400) @validation.query_schema(schema.index_query, '2.0', '2.74') - @validation.query_schema(schema.index_query_275, '2.75') + @validation.query_schema(schema.index_query_v275, '2.75', '2.101') + @validation.query_schema(schema.index_query_v2102, '2.102') @validation.response_body_schema(schema.detail_response, '2.0', '2.54') @validation.response_body_schema(schema.detail_response_v255, '2.55', '2.60') # noqa: E501 @validation.response_body_schema(schema.detail_response_v261, '2.61') @@ -232,6 +234,9 @@ class FlavorsController(wsgi.Controller): req.params['minDisk']) raise webob.exc.HTTPBadRequest(explanation=msg) + if 'name' in req.params: + filters['name'] = req.params['name'] + try: limited_flavors = objects.FlavorList.get_all( context, filters=filters, sort_key=sort_key, sort_dir=sort_dir, diff --git a/nova/api/openstack/compute/schemas/flavors.py b/nova/api/openstack/compute/schemas/flavors.py index 0246a099d71f..16f0e913f4ed 100644 --- a/nova/api/openstack/compute/schemas/flavors.py +++ b/nova/api/openstack/compute/schemas/flavors.py @@ -136,8 +136,12 @@ index_query = { 'additionalProperties': True } -index_query_275 = copy.deepcopy(index_query) -index_query_275['additionalProperties'] = False +index_query_v275 = copy.deepcopy(index_query) +index_query_v275['additionalProperties'] = False + +index_query_v2102 = copy.deepcopy(index_query_v275) +index_query_v2102['properties']['name'] = parameter_types.multi_params( + {'type': 'string'}) # TODO(stephenfin): Remove additionalProperties in a future API version show_query = { diff --git a/nova/objects/flavor.py b/nova/objects/flavor.py index 226b70ce050b..3a6357e78162 100644 --- a/nova/objects/flavor.py +++ b/nova/objects/flavor.py @@ -605,15 +605,25 @@ def _flavor_get_all_from_db(context, inactive, filters, sort_key, sort_dir, if 'min_memory_mb' in filters: query = query.filter( - api_models.Flavors.memory_mb >= filters['min_memory_mb']) + api_models.Flavors.memory_mb >= filters['min_memory_mb']) if 'min_root_gb' in filters: query = query.filter( - api_models.Flavors.root_gb >= filters['min_root_gb']) + api_models.Flavors.root_gb >= filters['min_root_gb']) if 'disabled' in filters: query = query.filter( - api_models.Flavors.disabled == filters['disabled']) + api_models.Flavors.disabled == filters['disabled']) + + if 'name' in filters: + # name can be a regex + safe_regex_filter, db_regexp_op = db_utils.get_regexp_ops( + CONF.database.connection) + query = query.filter( + api_models.Flavors.name.op(db_regexp_op)( + safe_regex_filter(filters['name']) + ) + ) if 'is_public' in filters and filters['is_public'] is not None: the_filter = [api_models.Flavors.is_public == filters['is_public']] @@ -624,6 +634,7 @@ def _flavor_get_all_from_db(context, inactive, filters, sort_key, sort_dir, query = query.filter(sa.or_(*the_filter)) else: query = query.filter(the_filter[0]) + marker_row = None if marker is not None: marker_row = Flavor._flavor_get_query_from_db(context).\ diff --git a/nova/tests/unit/api/openstack/compute/test_flavors.py b/nova/tests/unit/api/openstack/compute/test_flavors.py index 7621f30703bd..85e62a06e034 100644 --- a/nova/tests/unit/api/openstack/compute/test_flavors.py +++ b/nova/tests/unit/api/openstack/compute/test_flavors.py @@ -321,7 +321,7 @@ class FlavorsTestV21(test.TestCase): "href": "http://localhost/flavors/1", } ] - } + } ] if self.expect_description: expected_flavors[0]['description'] = ( @@ -854,6 +854,82 @@ class FlavorsTestV275(FlavorsTestV261): self.assertEqual(response_list['swap'], 0) +class FlavorsTestV2102(FlavorsTestV275): + microversion = '2.102' + + def test_list_flavors_with_name_filter_old_version(self): + req = fakes.HTTPRequestV21.blank( + '/flavors?name=false', version='2.101') + self.assertRaises( + exception.ValidationError, self.controller.index, req) + + def test_list_detail_flavors_with_name_filter_old_version(self): + req = fakes.HTTPRequestV21.blank( + '/flavors/detail?name=false', version='2.101') + self.assertRaises( + exception.ValidationError, self.controller.detail, req) + + def test_list_flavors_with_name_filter(self): + req = fakes.HTTPRequestV21.blank( + '/flavors?name=2', version=self.microversion) + actual = self.controller.index(req) + expected = { + 'flavors': [ + { + 'description': 'flavor 2 description', + 'id': '2', + 'links': [ + { + 'href': 'http://localhost/v2.1/flavors/2', + 'rel': 'self', + }, + { + 'href': 'http://localhost/flavors/2', + 'rel': 'bookmark', + }, + ], + 'name': 'flavor 2', + }, + ], + } + self.assertEqual(expected, actual) + + def test_list_detail_flavors_with_name_filter(self): + req = fakes.HTTPRequestV21.blank( + '/flavors/detail?name=2', version=self.microversion) + actual = self.controller.detail(req) + expected = { + 'flavors': [ + { + 'OS-FLV-DISABLED:disabled': fakes.FLAVORS['2'].disabled, + 'OS-FLV-EXT-DATA:ephemeral': + fakes.FLAVORS['2'].ephemeral_gb, + 'description': fakes.FLAVORS['2'].description, + 'disk': fakes.FLAVORS['2'].root_gb, + 'extra_specs': {}, + 'id': '2', + 'links': [ + { + 'href': 'http://localhost/v2.1/flavors/2', + 'rel': 'self', + }, + { + 'href': 'http://localhost/flavors/2', + 'rel': 'bookmark', + }, + ], + 'name': fakes.FLAVORS['2'].name, + 'os-flavor-access:is_public': True, + 'ram': fakes.FLAVORS['2'].memory_mb, + 'rxtx_factor': '', + 'swap': fakes.FLAVORS['2'].swap, + 'vcpus': fakes.FLAVORS['2'].vcpus, + }, + ], + } + self.assertEqual(expected, actual) + + class DisabledFlavorsWithRealDBTestV21(test.TestCase): """Tests that disabled flavors should not be shown nor listed.""" diff --git a/nova/tests/unit/api/openstack/fakes.py b/nova/tests/unit/api/openstack/fakes.py index d6c9541c4de8..fc5baff45d3a 100644 --- a/nova/tests/unit/api/openstack/fakes.py +++ b/nova/tests/unit/api/openstack/fakes.py @@ -780,6 +780,12 @@ def stub_out_flavor_get_all(test): elif reject_min('root_gb', 'min_root_gb'): continue + # in reality our filtering is regex based, but Python's regex + # format differs from MySQL (which differs from PostgreSQL, etc.) + # so we do a simple substring search instead + if 'name' in filters and filters['name'] not in flavor.name: + continue + res.append(flavor) res = sorted(res, key=lambda item: getattr(item, sort_key)) diff --git a/nova/tests/unit/objects/test_flavor.py b/nova/tests/unit/objects/test_flavor.py index 4172d3fda3a0..af99ffbe228d 100644 --- a/nova/tests/unit/objects/test_flavor.py +++ b/nova/tests/unit/objects/test_flavor.py @@ -390,10 +390,16 @@ class _TestFlavorList(object): self.assertEqual(len(api_flavors), len(flavors)) def test_get_all_from_db_with_limit(self): - flavors = objects.FlavorList.get_all(self.context, - limit=1) + flavors = objects.FlavorList.get_all(self.context, limit=1) self.assertEqual(1, len(flavors)) + def test_get_all_from_db_with_filters(self): + flavors = objects.FlavorList.get_all( + self.context, filters={'name': 'tiny'}) + # these flavors are created by the DefaultFlavorsFixture: there should + # be two: m1.tiny and m1.tiny.specs + self.assertEqual(2, len(flavors)) + @mock.patch('nova.objects.flavor._flavor_get_all_from_db') def test_get_all(self, mock_api_get): _fake_flavor = dict(fake_flavor, diff --git a/releasenotes/notes/flavor-name-search-4133a0788bd1c37f.yaml b/releasenotes/notes/flavor-name-search-4133a0788bd1c37f.yaml new file mode 100644 index 000000000000..ab4739214cd8 --- /dev/null +++ b/releasenotes/notes/flavor-name-search-4133a0788bd1c37f.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + The v2.102 microversion has been introduced. This allows users to search + flavors by name, e.g.:: + + GET /flavors?name=gpu