From e22381beda7dc531f0f3be8c7b02f22f37b3af6b Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Fri, 21 Mar 2025 11:03:16 +0000 Subject: [PATCH] api: Add schema for allocations API (requests) Change-Id: Ida8bd1d098246f6401605b1bd33ef47f82f4154b Signed-off-by: Stephen Finucane --- ironic/api/controllers/v1/allocation.py | 34 ++-- ironic/api/schemas/common/request_types.py | 26 +++ ironic/api/schemas/common/response_types.py | 27 +++ ironic/api/schemas/v1/allocation.py | 173 ++++++++++++++++++++ 4 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 ironic/api/schemas/common/request_types.py create mode 100644 ironic/api/schemas/common/response_types.py create mode 100644 ironic/api/schemas/v1/allocation.py diff --git a/ironic/api/controllers/v1/allocation.py b/ironic/api/controllers/v1/allocation.py index e1c9fc2452..8cd7578d4e 100644 --- a/ironic/api/controllers/v1/allocation.py +++ b/ironic/api/controllers/v1/allocation.py @@ -24,6 +24,7 @@ from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.api import method +from ironic.api.schemas.v1 import allocation as schema from ironic.api import validation from ironic.common import args from ironic.common import exception @@ -237,19 +238,16 @@ class AllocationsController(pecan.rest.RestController): @METRICS.timer('AllocationsController.get_all') @method.expose() + # TODO(stephenfin): We are currently using this for side-effects to e.g. + # convert a CSV string to an array or a string to an integer. We should + # probably rename this decorator or provide a separate, simpler decorator. + @args.validate(limit=args.integer, fields=args.string_list) @validation.api_version( min_version=versions.MINOR_52_ALLOCATION, message=_('The API version does not allow allocations'), ) - @args.validate(node=args.uuid_or_name, - resource_class=args.string, - state=args.string, - marker=args.uuid, - limit=args.integer, - sort_key=args.string, - sort_dir=args.string, - fields=args.string_list, - owner=args.string) + @validation.request_query_schema(schema.index_request_query, None, 59) + @validation.request_query_schema(schema.index_request_query_v60, 60) def get_all(self, node=None, resource_class=None, state=None, marker=None, limit=None, sort_key='id', sort_dir='asc', fields=None, owner=None): @@ -295,7 +293,13 @@ class AllocationsController(pecan.rest.RestController): min_version=versions.MINOR_52_ALLOCATION, message=_('The API version does not allow allocations'), ) - @args.validate(allocation_ident=args.uuid_or_name, fields=args.string_list) + # TODO(stephenfin): We are currently using this for side-effects to e.g. + # convert a CSV string to an array or a string to an integer. We should + # probably rename this decorator or provide a separate, simpler decorator. + @args.validate(fields=args.string_list) + @validation.request_parameter_schema(schema.show_request_parameter) + @validation.request_query_schema(schema.show_request_query, None, 59) + @validation.request_query_schema(schema.show_request_query_v60, 60) def get_one(self, allocation_ident, fields=None): """Retrieve information about the given allocation. @@ -311,7 +315,6 @@ class AllocationsController(pecan.rest.RestController): return convert_with_links(rpc_allocation, fields=fields) def _authorize_create_allocation(self, allocation): - try: # PRE-RBAC this rule was logically restricted, it is more-unlocked # post RBAC, but we need to ensure it is not abused. @@ -351,7 +354,8 @@ class AllocationsController(pecan.rest.RestController): message=_('The API version does not allow allocations'), exception_class=webob_exc.HTTPMethodNotAllowed, ) - @args.validate(allocation=ALLOCATION_VALIDATOR) + @validation.request_body_schema(schema.create_request_body, None, 57) + @validation.request_body_schema(schema.create_request_body_v58, 58) def post(self, allocation): """Create a new allocation. @@ -489,7 +493,9 @@ class AllocationsController(pecan.rest.RestController): message=_('The API version does not allow updating allocations'), exception_class=webob_exc.HTTPMethodNotAllowed, ) - @args.validate(allocation_ident=args.string, patch=args.patch) + @validation.request_parameter_schema(schema.update_request_parameter) + @validation.request_body_schema(schema.update_request_body, None, 59) + @validation.request_body_schema(schema.update_request_body_v60, 60) def patch(self, allocation_ident, patch): """Update an existing allocation. @@ -533,7 +539,7 @@ class AllocationsController(pecan.rest.RestController): message=_('The API version does not allow allocations'), exception_class=webob_exc.HTTPMethodNotAllowed, ) - @args.validate(allocation_ident=args.uuid_or_name) + @validation.request_parameter_schema(schema.delete_request_parameter) def delete(self, allocation_ident): """Delete an allocation. diff --git a/ironic/api/schemas/common/request_types.py b/ironic/api/schemas/common/request_types.py new file mode 100644 index 0000000000..03e86f7001 --- /dev/null +++ b/ironic/api/schemas/common/request_types.py @@ -0,0 +1,26 @@ +# 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. + + +uuid_or_name = { + 'anyOf': [ + {'type': 'string', 'format': 'uuid'}, + { + 'type': 'string', + 'minLength': 1, + 'maxLength': 255, + 'pattern': r'^[a-zA-Z0-9-._~]+$', + }, + ] +} + +sort_dir = {'type': 'string', 'enum': ['asc', 'desc'], 'default': 'asc'} diff --git a/ironic/api/schemas/common/response_types.py b/ironic/api/schemas/common/response_types.py new file mode 100644 index 0000000000..b73586863b --- /dev/null +++ b/ironic/api/schemas/common/response_types.py @@ -0,0 +1,27 @@ +# 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 os_traits + + +STANDARD_TRAITS = os_traits.get_traits() +CUSTOM_TRAIT_PATTERN = "^%s[A-Z0-9_]+$" % os_traits.CUSTOM_NAMESPACE + +traits = { + 'type': 'string', + 'minLength': 1, + 'maxLength': 255, + 'anyOf': [ + {'pattern': CUSTOM_TRAIT_PATTERN}, + {'enum': STANDARD_TRAITS}, + ] +} diff --git a/ironic/api/schemas/v1/allocation.py b/ironic/api/schemas/v1/allocation.py new file mode 100644 index 0000000000..0d7c289f0b --- /dev/null +++ b/ironic/api/schemas/v1/allocation.py @@ -0,0 +1,173 @@ +# 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 ironic.api.schemas.common import request_types +from ironic.api.schemas.common import response_types + + +# request parameter schemas + +_allocation_request_parameter = { + 'type': 'object', + 'properties': { + 'allocation_ident': {'type': 'string'}, + }, + 'required': ['allocation_ident'], + 'additionalProperties': False, +} +show_request_parameter = copy.deepcopy(_allocation_request_parameter) +update_request_parameter = copy.deepcopy(_allocation_request_parameter) +delete_request_parameter = copy.deepcopy(_allocation_request_parameter) + +# request query string schemas + +index_request_query = { + 'type': 'object', + 'properties': { + 'fields': { + 'type': 'array', + 'items': { + 'enum': [ + 'candidate_nodes', + 'created_at', + 'extra', + 'last_error', + 'links', + 'name', + 'node_uuid', + 'resource_class', + 'state', + 'traits', + 'updated_at', + 'uuid', + ], + }, + # OpenAPI-specific properties + # https://swagger.io/docs/specification/v3_0/serialization/#query-parameters + 'style': 'form', + 'explode': False, + }, + 'limit': {'type': 'integer'}, + 'marker': {'type': 'string', 'format': 'uuid'}, + 'node': request_types.uuid_or_name, + 'owner': {'type': 'string'}, + 'resource_class': {'type': 'string'}, + 'sort_dir': request_types.sort_dir, + # TODO(stephenfin): This could probably be narrower but we need to be + # careful not to change the response type. If we do, a new microversion + # will be needed. + 'sort_key': {'type': 'string'}, + 'state': {'type': 'string'}, + }, + 'required': [], + 'additionalProperties': False, +} + +index_request_query_v60 = copy.deepcopy(index_request_query) +index_request_query_v60['properties']['fields']['items']['enum'].append( + 'owner' +) + +show_request_query = { + 'type': 'object', + 'properties': { + 'fields': { + 'type': 'array', + 'items': { + 'enum': [ + 'candidate_nodes', + 'created_at', + 'extra', + 'last_error', + 'links', + 'name', + 'node_uuid', + 'resource_class', + 'state', + 'traits', + 'updated_at', + 'uuid', + ], + }, + # OpenAPI-specific properties + # https://swagger.io/docs/specification/v3_0/serialization/#query-parameters + 'style': 'form', + 'explode': False, + }, + }, + 'required': [], + 'additionalProperties': False, +} + +show_request_query_v60 = copy.deepcopy(show_request_query) +show_request_query_v60['properties']['fields']['items']['enum'].append( + 'owner' +) + +# request body schemas + +create_request_body = { + 'type': 'object', + 'properties': { + 'candidate_nodes': { + 'type': ['array', 'null'], + 'items': request_types.uuid_or_name, + }, + 'extra': {'type': ['object', 'null']}, + # TODO(stephenfin): We'd like to use request_types.uuid_or_name here + # but doing so will change the error response + 'name': {'type': ['string', 'null']}, + # TODO(stephenfin): The docs say that owner is only present in v1.60+, + # but I can't see anything in the code to prevent this in the POST + # request, only in the GET request and all responses + 'owner': {'type': ['string', 'null']}, + 'resource_class': {'type': ['string', 'null'], 'maxLength': 80}, + 'traits': { + 'type': ['array', 'null'], + 'items': response_types.traits, + }, + 'uuid': {'type': ['string', 'null']}, + }, + # TODO(stephenfin): The resource_class field is required when node is not + # provided. We'd like to express this here, but doing so will change the + # error response. + 'required': [], + 'additionalProperties': False, +} + +create_request_body_v58 = copy.deepcopy(create_request_body) +create_request_body_v58['properties'].update({ + 'node': {'type': ['string', 'null']}, +}) + +# TODO(stephenfin): This needs to be completed. We probably want a helper to +# generate these since they are superficially identical, with only the allowed +# patch fields changing +update_request_body = { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'op': {'enum': ['add', 'replace', 'remove']}, + 'path': {'type': 'string'}, + 'value': {'type': ['string', 'object', 'null']}, + }, + 'required': ['op', 'path'], + 'additionalProperties': False, + }, +} + +# TODO(stephenfin): The code suggests that we should be allowing 'owner' here, +# but it's not included in PATCH_ALLOWED_FIELDS so I have ignored it for now +update_request_body_v60 = copy.deepcopy(update_request_body)