diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 328d020a56..ba36d261c6 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -17,7 +17,6 @@ import copy import datetime from http import client as http_client import json -import os from ironic_lib import metrics_utils import jsonschema @@ -123,62 +122,75 @@ ALLOWED_TARGET_POWER_STATES = (ir_states.POWER_ON, _NODE_DESCRIPTION_MAX_LENGTH = 4096 -with open(os.path.join(os.path.dirname(__file__), - 'network-data-schema.json'), 'rb') as fl: - NETWORK_DATA_SCHEMA = json.load(fl) +_NETWORK_DATA_SCHEMA = None -NODE_SCHEMA = { - 'type': 'object', - 'properties': { - 'automated_clean': {'type': ['string', 'boolean', 'null']}, - 'bios_interface': {'type': ['string', 'null']}, - 'boot_interface': {'type': ['string', 'null']}, - 'chassis_uuid': {'type': ['string', 'null']}, - 'conductor_group': {'type': ['string', 'null']}, - 'console_enabled': {'type': ['string', 'boolean', 'null']}, - 'console_interface': {'type': ['string', 'null']}, - 'deploy_interface': {'type': ['string', 'null']}, - 'description': {'type': ['string', 'null'], - 'maxLength': _NODE_DESCRIPTION_MAX_LENGTH}, - 'driver': {'type': 'string'}, - 'driver_info': {'type': ['object', 'null']}, - 'extra': {'type': ['object', 'null']}, - 'inspect_interface': {'type': ['string', 'null']}, - 'instance_info': {'type': ['object', 'null']}, - 'instance_uuid': {'type': ['string', 'null']}, - 'lessee': {'type': ['string', 'null']}, - 'management_interface': {'type': ['string', 'null']}, - 'maintenance': {'type': ['string', 'boolean', 'null']}, - 'name': {'type': ['string', 'null']}, - 'network_data': {'anyOf': [ - {'type': 'null'}, - {'type': 'object', 'additionalProperties': False}, - NETWORK_DATA_SCHEMA - ]}, - 'network_interface': {'type': ['string', 'null']}, - 'owner': {'type': ['string', 'null']}, - 'power_interface': {'type': ['string', 'null']}, - 'properties': {'type': ['object', 'null']}, - 'raid_interface': {'type': ['string', 'null']}, - 'rescue_interface': {'type': ['string', 'null']}, - 'resource_class': {'type': ['string', 'null'], 'maxLength': 80}, - 'retired': {'type': ['string', 'boolean', 'null']}, - 'retired_reason': {'type': ['string', 'null']}, - 'storage_interface': {'type': ['string', 'null']}, - 'uuid': {'type': ['string', 'null']}, - 'vendor_interface': {'type': ['string', 'null']}, - }, - 'required': ['driver'], - 'additionalProperties': False, - 'definitions': NETWORK_DATA_SCHEMA.get('definitions') -} -NODE_PATCH_SCHEMA = copy.deepcopy(NODE_SCHEMA) -# add schema for patchable fields -NODE_PATCH_SCHEMA['properties']['protected'] = { - 'type': ['string', 'boolean', 'null']} -NODE_PATCH_SCHEMA['properties']['protected_reason'] = { - 'type': ['string', 'null']} +def network_data_schema(): + global _NETWORK_DATA_SCHEMA + if _NETWORK_DATA_SCHEMA is None: + with open(CONF.api.network_data_schema) as fl: + _NETWORK_DATA_SCHEMA = json.load(fl) + return _NETWORK_DATA_SCHEMA + + +def node_schema(): + network_data = network_data_schema() + return { + 'type': 'object', + 'properties': { + 'automated_clean': {'type': ['string', 'boolean', 'null']}, + 'bios_interface': {'type': ['string', 'null']}, + 'boot_interface': {'type': ['string', 'null']}, + 'chassis_uuid': {'type': ['string', 'null']}, + 'conductor_group': {'type': ['string', 'null']}, + 'console_enabled': {'type': ['string', 'boolean', 'null']}, + 'console_interface': {'type': ['string', 'null']}, + 'deploy_interface': {'type': ['string', 'null']}, + 'description': {'type': ['string', 'null'], + 'maxLength': _NODE_DESCRIPTION_MAX_LENGTH}, + 'driver': {'type': 'string'}, + 'driver_info': {'type': ['object', 'null']}, + 'extra': {'type': ['object', 'null']}, + 'inspect_interface': {'type': ['string', 'null']}, + 'instance_info': {'type': ['object', 'null']}, + 'instance_uuid': {'type': ['string', 'null']}, + 'lessee': {'type': ['string', 'null']}, + 'management_interface': {'type': ['string', 'null']}, + 'maintenance': {'type': ['string', 'boolean', 'null']}, + 'name': {'type': ['string', 'null']}, + 'network_data': {'anyOf': [ + {'type': 'null'}, + {'type': 'object', 'additionalProperties': False}, + network_data + ]}, + 'network_interface': {'type': ['string', 'null']}, + 'owner': {'type': ['string', 'null']}, + 'power_interface': {'type': ['string', 'null']}, + 'properties': {'type': ['object', 'null']}, + 'raid_interface': {'type': ['string', 'null']}, + 'rescue_interface': {'type': ['string', 'null']}, + 'resource_class': {'type': ['string', 'null'], 'maxLength': 80}, + 'retired': {'type': ['string', 'boolean', 'null']}, + 'retired_reason': {'type': ['string', 'null']}, + 'storage_interface': {'type': ['string', 'null']}, + 'uuid': {'type': ['string', 'null']}, + 'vendor_interface': {'type': ['string', 'null']}, + }, + 'required': ['driver'], + 'additionalProperties': False, + 'definitions': network_data.get('definitions', {}) + } + + +def node_patch_schema(): + node_patch = copy.deepcopy(node_schema()) + # add schema for patchable fields + node_patch['properties']['protected'] = { + 'type': ['string', 'boolean', 'null']} + node_patch['properties']['protected_reason'] = { + 'type': ['string', 'null']} + return node_patch + NODE_VALIDATE_EXTRA = args.dict_valid( automated_clean=args.boolean, @@ -191,15 +203,30 @@ NODE_VALIDATE_EXTRA = args.dict_valid( uuid=args.uuid, ) -NODE_VALIDATOR = args.and_valid( - args.schema(NODE_SCHEMA), - NODE_VALIDATE_EXTRA -) -NODE_PATCH_VALIDATOR = args.and_valid( - args.schema(NODE_PATCH_SCHEMA), - NODE_VALIDATE_EXTRA -) +_NODE_VALIDATOR = None +_NODE_PATCH_VALIDATOR = None + + +def node_validator(name, value): + global _NODE_VALIDATOR + if _NODE_VALIDATOR is None: + _NODE_VALIDATOR = args.and_valid( + args.schema(node_schema()), + NODE_VALIDATE_EXTRA + ) + return _NODE_VALIDATOR(name, value) + + +def node_patch_validator(name, value): + global _NODE_PATCH_VALIDATOR + if _NODE_PATCH_VALIDATOR is None: + _NODE_PATCH_VALIDATOR = args.and_valid( + args.schema(node_patch_schema()), + NODE_VALIDATE_EXTRA + ) + return _NODE_PATCH_VALIDATOR(name, value) + PATCH_ALLOWED_FIELDS = [ 'automated_clean', @@ -334,7 +361,7 @@ def validate_network_data(network_data): :raises: Invalid if network data is not schema-compliant """ try: - jsonschema.validate(network_data, NETWORK_DATA_SCHEMA) + jsonschema.validate(network_data, network_data_schema()) except json_schema_exc.ValidationError as e: # NOTE: Even though e.message is deprecated in general, it is @@ -1838,7 +1865,7 @@ class NodesController(rest.RestController): api_utils.patch_update_changed_fields( node, rpc_node, fields=set(objects.Node.fields) - {'traits'}, - schema=NODE_PATCH_SCHEMA, + schema=node_patch_schema(), id_map={'chassis_id': chassis and chassis.id or None} ) @@ -2098,7 +2125,7 @@ class NodesController(rest.RestController): @METRICS.timer('NodesController.post') @method.expose(status_code=http_client.CREATED) @method.body('node') - @args.validate(node=NODE_VALIDATOR) + @args.validate(node=node_validator) def post(self, node): """Create a new node. @@ -2335,7 +2362,7 @@ class NodesController(rest.RestController): node_dict = api_utils.apply_jsonpatch(node_dict, patch) api_utils.patched_validate_with_schema( - node_dict, NODE_PATCH_SCHEMA, NODE_PATCH_VALIDATOR) + node_dict, node_patch_schema(), node_patch_validator) self._update_changed_fields(node_dict, rpc_node) # NOTE(tenbrae): we calculate the rpc topic here in case node.driver diff --git a/ironic/conf/api.py b/ironic/conf/api.py index ba23627b08..dcf235eddc 100644 --- a/ironic/conf/api.py +++ b/ironic/conf/api.py @@ -66,6 +66,10 @@ opts = [ default=300, mutable=True, help=_('Maximum interval (in seconds) for agent heartbeats.')), + cfg.StrOpt( + 'network_data_schema', + default='$pybasedir/api/controllers/v1/network-data-schema.json', + help=_("Schema for network data used by this deployment.")), ] opt_group = cfg.OptGroup(name='api', diff --git a/ironic/tests/unit/api/base.py b/ironic/tests/unit/api/base.py index ebf89df32a..6670be14e3 100644 --- a/ironic/tests/unit/api/base.py +++ b/ironic/tests/unit/api/base.py @@ -27,6 +27,7 @@ from oslo_config import cfg import pecan import pecan.testing +from ironic.api.controllers.v1 import node as api_node from ironic.tests.unit.db import base as db_base PATH_PREFIX = '/v1' @@ -65,6 +66,10 @@ class BaseApiTest(db_base.DbTestCase): self._check_version = p.start() self.addCleanup(p.stop) + api_node._NETWORK_DATA_SCHEMA = None + api_node._NODE_VALIDATOR = None + api_node._NODE_PATCH_VALIDATOR = None + def _make_app(self): # Determine where we are so we can set up paths in the config root_dir = self.path_get() diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 185e8f27e1..302da10551 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -17,6 +17,7 @@ import datetime from http import client as http_client import json import os +import tempfile from unittest import mock from urllib import parse as urlparse @@ -3807,6 +3808,47 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + def test_update_network_data_wrong_format(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid()) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.66'} + + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/network_data', + 'value': {'cat': 'meow'}, + 'op': 'replace'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.BAD_REQUEST, response.status_code) + + def test_update_network_data_custom(self): + custom_schema = { + 'type': 'object', + 'properties': { + 'cat': {'type': 'string'}, + }, + } + with tempfile.NamedTemporaryFile('wt') as fp: + json.dump(custom_schema, fp) + fp.flush() + self.config(network_data_schema=fp.name, group='api') + + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state='active') + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.66'} + + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/network_data', + 'value': {'cat': 'meow'}, + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + @mock.patch.object(api_utils, 'check_multiple_node_policies_and_retrieve', autospec=True) def test_patch_policy_update(self, mock_cmnpar): diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index d3963fc15a..9be350e8b5 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -111,7 +111,7 @@ def node_post_data(**kw): node.pop(field, None) return remove_other_fields( - node, node_controller.NODE_SCHEMA['properties']) + node, node_controller.node_schema()['properties']) def port_post_data(**kw): diff --git a/releasenotes/notes/network_data_schema-9342edf3c47b2a66.yaml b/releasenotes/notes/network_data_schema-9342edf3c47b2a66.yaml new file mode 100644 index 0000000000..803551bb41 --- /dev/null +++ b/releasenotes/notes/network_data_schema-9342edf3c47b2a66.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + The network data schema is now configurable via the new configuration + options ``[api]network_data_schema``.