diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index f52145a7a1..c313bee163 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,11 @@ REST API Version History ======================== +1.47 (Stein, master) +-------------------- + +Added ``automated_clean`` field to the node object, enabling cleaning per node. + 1.46 (Rocky, 11.1.0) -------------------- Added ``conductor_group`` field to the node and the node response, diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 1808a4f942..87c6324728 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -171,6 +171,9 @@ def _hide_fields_in_newer_versions_part_two(obj): if not api_utils.allow_conductor_group(): obj.conductor_group = wsme.Unset + if not api_utils.allow_automated_clean(): + obj.automated_clean = wsme.Unset + def hide_fields_in_newer_versions(obj): """This method hides fields that were added in newer API versions. @@ -1089,6 +1092,9 @@ class Node(base.APIBase): conductor_group = wsme.wsattr(wtypes.text) """The conductor group to manage this node""" + automated_clean = types.boolean + """Indicates whether the node will perform automated clean or not.""" + # NOTE(deva): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -1249,7 +1255,8 @@ class Node(base.APIBase): management_interface=None, power_interface=None, raid_interface=None, vendor_interface=None, storage_interface=None, traits=[], rescue_interface=None, - bios_interface=None, conductor_group="") + bios_interface=None, conductor_group="", + automated_clean=None) # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1934,6 +1941,10 @@ class NodesController(rest.RestController): and node.conductor_group != ""): raise exception.NotAcceptable() + if (not api_utils.allow_automated_clean() + and node.automated_clean is not wtypes.Unset): + raise exception.NotAcceptable() + # NOTE(deva): get_topic_for checks if node.driver is in the hash ring # and raises NoValidHost if it is not. # We need to ensure that node has a UUID before it can @@ -2018,6 +2029,10 @@ class NodesController(rest.RestController): if conductor_group and not api_utils.allow_conductor_group(): raise exception.NotAcceptable() + automated_clean = api_utils.get_patch_values(patch, '/automated_clean') + if automated_clean and not api_utils.allow_automated_clean(): + raise exception.NotAcceptable() + @METRICS.timer('NodesController.patch') @wsme.validate(types.uuid, types.boolean, [NodePatchType]) @expose.expose(Node, types.uuid_or_name, types.boolean, diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 36d89869c1..52ccef183e 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -383,6 +383,8 @@ def check_allowed_fields(fields): raise exception.NotAcceptable() if 'conductor_group' in fields and not allow_conductor_group(): raise exception.NotAcceptable() + if 'automated_clean' in fields and not allow_automated_clean(): + raise exception.NotAcceptable() def check_allowed_portgroup_fields(fields): @@ -896,6 +898,15 @@ def allow_conductor_group(): versions.MINOR_46_NODE_CONDUCTOR_GROUP) +def allow_automated_clean(): + """Check if passing automated_clean for a node is allowed. + + Version 1.47 exposes this field. + """ + return (pecan.request.version.minor >= + versions.MINOR_47_NODE_AUTOMATED_CLEAN) + + def get_request_return_fields(fields, detail, default_fields): """Calculate fields to return from an API request diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 650e8be78d..f7c5c02d25 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -84,6 +84,7 @@ BASE_VERSION = 1 # v1.44: Add node deploy_step field # v1.45: reset_interfaces parameter to node's PATCH # v1.46: Add conductor_group to the node object. +# v1.47: Add automated_clean to the node object. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -132,6 +133,7 @@ MINOR_43_ENABLE_DETAIL_QUERY = 43 MINOR_44_NODE_DEPLOY_STEP = 44 MINOR_45_RESET_INTERFACES = 45 MINOR_46_NODE_CONDUCTOR_GROUP = 46 +MINOR_47_NODE_AUTOMATED_CLEAN = 47 # When adding another version, update: # - MINOR_MAX_VERSION @@ -139,7 +141,7 @@ MINOR_46_NODE_CONDUCTOR_GROUP = 46 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_46_NODE_CONDUCTOR_GROUP +MINOR_MAX_VERSION = MINOR_47_NODE_AUTOMATED_CLEAN # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 477e33b73e..2cdbe60b45 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -131,7 +131,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.46', + 'api': '1.47', 'rpc': '1.47', 'objects': { 'Node': ['1.28'], diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index ccdada419b..26e00ce28d 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -124,6 +124,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('bios_interface', data['nodes'][0]) self.assertNotIn('deploy_step', data['nodes'][0]) self.assertNotIn('conductor_group', data['nodes'][0]) + self.assertNotIn('automated_clean', data['nodes'][0]) def test_get_one(self): node = obj_utils.create_test_node(self.context, @@ -162,6 +163,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('bios_interface', data) self.assertIn('deploy_step', data) self.assertIn('conductor_group', data) + self.assertIn('automated_clean', data) def test_get_one_with_json(self): # Test backward compatibility with guess_content_type_from_ext @@ -262,6 +264,28 @@ class TestListNodes(test_api_base.BaseApiTest): self._test_node_field_hidden_in_lower_version('conductor_group', '1.45', '1.46') + def test_node_automated_clean_hidden_in_lower_version(self): + self._test_node_field_hidden_in_lower_version('automated_clean', + '1.46', '1.47') + + def test_node_automated_clean_null_field(self): + node = obj_utils.create_test_node(self.context, automated_clean=None) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.47'}) + self.assertIsNone(data['automated_clean']) + + def test_node_automated_clean_true_field(self): + node = obj_utils.create_test_node(self.context, automated_clean=True) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.47'}) + self.assertEqual(data['automated_clean'], True) + + def test_node_automated_clean_false_field(self): + node = obj_utils.create_test_node(self.context, automated_clean=False) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.47'}) + self.assertEqual(data['automated_clean'], False) + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -418,6 +442,14 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.46'}) self.assertIn('conductor_group', response) + def test_get_automated_clean_fields(self): + node = obj_utils.create_test_node(self.context, + automated_clean=True) + fields = 'automated_clean' + response = self.get_json('/nodes/%s?fields=%s' % (node.uuid, fields), + headers={api_base.Version.string: '1.47'}) + self.assertIn('automated_clean', response) + def test_detail(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -448,6 +480,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('storage_interface', data['nodes'][0]) self.assertIn('traits', data['nodes'][0]) self.assertIn('conductor_group', data['nodes'][0]) + self.assertIn('automated_clean', data['nodes'][0]) # never expose the chassis_id self.assertNotIn('chassis_id', data['nodes'][0]) @@ -477,6 +510,7 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn('network_interface', data['nodes'][0]) self.assertIn('resource_class', data['nodes'][0]) self.assertIn('conductor_group', data['nodes'][0]) + self.assertIn('automated_clean', data['nodes'][0]) for field in api_utils.V31_FIELDS: self.assertIn(field, data['nodes'][0]) # never expose the chassis_id @@ -2558,6 +2592,33 @@ 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_automated_clean(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.47'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/automated_clean', + 'value': True, + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_automated_clean_old_api(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.46'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/automated_clean', + 'value': True, + 'op': 'replace'}], + headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code) + def _create_node_locally(node): driver_factory.check_and_update_node_interfaces(node) @@ -3137,6 +3198,26 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertEqual(http_client.BAD_REQUEST, response.status_int) + def test_create_node_automated_clean(self): + ndict = test_api_utils.post_get_test_node( + automated_clean=True) + response = self.post_json('/nodes', ndict, + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(http_client.CREATED, response.status_int) + result = self.get_json('/nodes/%s' % ndict['uuid'], + headers={api_base.Version.string: + str(api_v1.max_version())}) + self.assertEqual(True, result['automated_clean']) + + def test_create_node_automated_clean_old_api_version(self): + headers = {api_base.Version.string: '1.32'} + ndict = test_api_utils.post_get_test_node(automated_clean=True) + response = self.post_json('/nodes', ndict, headers=headers, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int) + class TestDelete(test_api_base.BaseApiTest): diff --git a/releasenotes/notes/add_automated_clean_field-b3e7d56f4aeaf512.yaml b/releasenotes/notes/add_automated_clean_field-b3e7d56f4aeaf512.yaml new file mode 100644 index 0000000000..c9d09a3409 --- /dev/null +++ b/releasenotes/notes/add_automated_clean_field-b3e7d56f4aeaf512.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Allows enabling automated cleaning per node if it is disabled globally. + A new ``automated_clean`` field has been created on the node object, + allowing to control the individual automated cleaning of nodes. + When automated cleaning is disabled at global level, but enabled at node + level, the automated cleaning will be performed only on those nodes. + + The new field is accessible starting with the API version 1.47.