diff --git a/api-ref/source/baremetal-api-v1-nodes.inc b/api-ref/source/baremetal-api-v1-nodes.inc index 570759f305..78e8a0f1c0 100644 --- a/api-ref/source/baremetal-api-v1-nodes.inc +++ b/api-ref/source/baremetal-api-v1-nodes.inc @@ -426,6 +426,8 @@ Response - description: n_description - conductor: conductor - allocation_uuid: allocation_uuid + - retired: retired + - retired_reason: retired_reason **Example detailed list of Nodes:** @@ -469,6 +471,9 @@ only the specified set. .. versionadded:: 1.52 Introduced the ``allocation_uuid`` field. +.. versionadded:: 1.61 + Introduced the ``retired`` and ``retired_reason`` fields. + Normal response codes: 200 Error codes: 400,403,404,406 diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 66e066c523..4967fc61d5 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1542,6 +1542,20 @@ response_driver_type: in: body required: true type: string +retired: + description: | + Whether the node is retired and can hence no longer be provided, i.e. move + from ``manageable`` to ``available``, and will end up in ``manageable`` + after cleaning (rather than ``available``). + in: body + required: false + type: boolean +retired_reason: + description: | + The reason the node is marked as retired. + in: body + required: false + type: string standalone_ports_supported: description: | Indicates whether ports that are members of this portgroup can be diff --git a/api-ref/source/samples/node-create-response.json b/api-ref/source/samples/node-create-response.json index 08692249a2..84932a2356 100644 --- a/api-ref/source/samples/node-create-response.json +++ b/api-ref/source/samples/node-create-response.json @@ -71,6 +71,8 @@ "rescue_interface": null, "reservation": null, "resource_class": "bm-large", + "retired": false, + "retired_reason": null, "states": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", diff --git a/api-ref/source/samples/node-show-response.json b/api-ref/source/samples/node-show-response.json index 890a311ed6..3a520c96bf 100644 --- a/api-ref/source/samples/node-show-response.json +++ b/api-ref/source/samples/node-show-response.json @@ -74,6 +74,8 @@ "rescue_interface": null, "reservation": null, "resource_class": "bm-large", + "retired": false, + "retired_reason": null, "states": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", diff --git a/api-ref/source/samples/node-update-driver-info-response.json b/api-ref/source/samples/node-update-driver-info-response.json index a3155aa303..05665a3dc7 100644 --- a/api-ref/source/samples/node-update-driver-info-response.json +++ b/api-ref/source/samples/node-update-driver-info-response.json @@ -75,6 +75,8 @@ "rescue_interface": null, "reservation": null, "resource_class": null, + "retired": false, + "retired_reason": null, "states": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", diff --git a/api-ref/source/samples/nodes-list-details-response.json b/api-ref/source/samples/nodes-list-details-response.json index 701ac82e75..870a625586 100644 --- a/api-ref/source/samples/nodes-list-details-response.json +++ b/api-ref/source/samples/nodes-list-details-response.json @@ -76,6 +76,8 @@ "rescue_interface": null, "reservation": null, "resource_class": null, + "retired": false, + "retired_reason": null, "states": [ { "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", @@ -178,6 +180,8 @@ "rescue_interface": "no-rescue", "reservation": null, "resource_class": null, + "retired": false, + "retired_reason": null, "states": [ { "href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428/states", diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index d2dcb73865..0f3c688302 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,15 @@ REST API Version History ======================== +1.61 (Ussuri, master) +--------------------- + +Added ``retired`` field to the node object to mark nodes for retirement. +If set, this flag will move nodes to ``manageable`` upon automatic +cleaning. ``manageable`` nodes which have this flag set cannot be +moved to available. Also added ``retired_reason`` to specify the +retirement reason. + 1.60 (Ussuri, master) --------------------- diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index c322d3e20e..6f4814788c 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -1075,6 +1075,12 @@ class Node(base.APIBase): allocation_uuid = wsme.wsattr(types.uuid, readonly=True) """The UUID of the allocation this node belongs""" + retired = types.boolean + """Indicates whether the node is marked for retirement.""" + + retired_reason = wsme.wsattr(str) + """Indicates the reason for a node's retirement.""" + # NOTE(deva): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -1291,7 +1297,8 @@ class Node(base.APIBase): bios_interface=None, conductor_group="", automated_clean=None, protected=False, protected_reason=None, owner=None, - allocation_uuid='982ddb5b-bce5-4d23-8fb8-7f710f648cd5') + allocation_uuid='982ddb5b-bce5-4d23-8fb8-7f710f648cd5', + retired=False, retired_reason=None) # NOTE(matty_dubs): The chassis_uuid getter() is based on the # _chassis_uuid variable: sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' @@ -1605,8 +1612,8 @@ class NodesController(rest.RestController): return filtered_nodes def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated, - maintenance, provision_state, marker, limit, - sort_key, sort_dir, driver=None, + maintenance, retired, provision_state, marker, + limit, sort_key, sort_dir, driver=None, resource_class=None, resource_url=None, fields=None, fault=None, conductor_group=None, detail=None, conductor=None, owner=None, @@ -1654,6 +1661,7 @@ class NodesController(rest.RestController): 'conductor_group': conductor_group, 'owner': owner, 'description_contains': description_contains, + 'retired': retired, } filters = {} for key, value in possible_filters.items(): @@ -1673,6 +1681,8 @@ class NodesController(rest.RestController): parameters['associated'] = associated if maintenance: parameters['maintenance'] = maintenance + if retired: + parameters['retired'] = retired if detail is not None: parameters['detail'] = detail @@ -1773,14 +1783,14 @@ class NodesController(rest.RestController): @METRICS.timer('NodesController.get_all') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, - types.boolean, str, types.uuid, int, str, + types.boolean, types.boolean, str, types.uuid, int, str, str, str, types.listtype, str, str, str, types.boolean, str, str, str) def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, - maintenance=None, provision_state=None, marker=None, - limit=None, sort_key='id', sort_dir='asc', driver=None, - fields=None, resource_class=None, fault=None, + maintenance=None, retired=None, provision_state=None, + marker=None, limit=None, sort_key='id', sort_dir='asc', + driver=None, fields=None, resource_class=None, fault=None, conductor_group=None, detail=None, conductor=None, owner=None, description_contains=None): """Retrieve a list of nodes. @@ -1795,6 +1805,8 @@ class NodesController(rest.RestController): :param maintenance: Optional boolean value that indicates whether to get nodes in maintenance mode ("True"), or not in maintenance mode ("False"). + :param retired: Optional boolean value that indicates whether + to get retired nodes. :param provision_state: Optional string value to get only nodes in that provision state. :param marker: pagination marker for large data sets. @@ -1839,7 +1851,7 @@ class NodesController(rest.RestController): extra_args = {'description_contains': description_contains} return self._get_nodes_collection(chassis_uuid, instance_uuid, - associated, maintenance, + associated, maintenance, retired, provision_state, marker, limit, sort_key, sort_dir, driver=driver, @@ -1853,14 +1865,15 @@ class NodesController(rest.RestController): @METRICS.timer('NodesController.detail') @expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, - types.boolean, str, types.uuid, int, str, + types.boolean, types.boolean, str, types.uuid, int, str, str, str, str, str, str, str, str, str) def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, - maintenance=None, provision_state=None, marker=None, - limit=None, sort_key='id', sort_dir='asc', driver=None, - resource_class=None, fault=None, conductor_group=None, - conductor=None, owner=None, description_contains=None): + maintenance=None, retired=None, provision_state=None, + marker=None, limit=None, sort_key='id', sort_dir='asc', + driver=None, resource_class=None, fault=None, + conductor_group=None, conductor=None, owner=None, + description_contains=None): """Retrieve a list of nodes with detail. :param chassis_uuid: Optional UUID of a chassis, to get only nodes for @@ -1873,6 +1886,8 @@ class NodesController(rest.RestController): :param maintenance: Optional boolean value that indicates whether to get nodes in maintenance mode ("True"), or not in maintenance mode ("False"). + :param retired: Optional boolean value that indicates whether + to get nodes which are retired. :param provision_state: Optional string value to get only nodes in that provision state. :param marker: pagination marker for large data sets. @@ -1914,7 +1929,7 @@ class NodesController(rest.RestController): resource_url = '/'.join(['nodes', 'detail']) extra_args = {'description_contains': description_contains} return self._get_nodes_collection(chassis_uuid, instance_uuid, - associated, maintenance, + associated, maintenance, retired, provision_state, marker, limit, sort_key, sort_dir, driver=driver, diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 7712fc30f0..e635f2eaea 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -487,6 +487,8 @@ VERSIONED_FIELDS = { 'description': versions.MINOR_51_NODE_DESCRIPTION, 'allocation_uuid': versions.MINOR_52_ALLOCATION, 'events': versions.MINOR_54_EVENTS, + 'retired': versions.MINOR_61_NODE_RETIRED, + 'retired_reason': versions.MINOR_61_NODE_RETIRED, } for field in V31_FIELDS: diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 19a3ac4a14..99e04b60e2 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -160,6 +160,7 @@ MINOR_57_ALLOCATION_UPDATE = 57 MINOR_58_ALLOCATION_BACKFILL = 58 MINOR_59_CONFIGDRIVE_VENDOR_DATA = 59 MINOR_60_ALLOCATION_OWNER = 60 +MINOR_61_NODE_RETIRED = 61 # When adding another version, update: # - MINOR_MAX_VERSION @@ -167,7 +168,7 @@ MINOR_60_ALLOCATION_OWNER = 60 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_60_ALLOCATION_OWNER +MINOR_MAX_VERSION = MINOR_61_NODE_RETIRED # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/exception.py b/ironic/common/exception.py index 1daee6318c..9c949df4d4 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -710,3 +710,8 @@ class IBMCConnectionError(IBMCError): class ClientSideError(wsme.exc.ClientSideError): pass + + +class NodeIsRetired(Invalid): + _msg_fmt = _("The %(op)s operation can't be performed on node " + "%(node)s because it is retired.") diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 1f619aa0e0..e2f4b8e23a 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -197,11 +197,11 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.60', + 'api': '1.61', 'rpc': '1.48', 'objects': { 'Allocation': ['1.1'], - 'Node': ['1.32'], + 'Node': ['1.33', '1.32'], 'Conductor': ['1.3'], 'Chassis': ['1.3'], 'DeployTemplate': ['1.1'], diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 2f7ac081a7..509fb2af38 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -151,6 +151,22 @@ class ConductorManager(base_manager.BaseConductorManager): "The protected_reason field can only be set when " "protected is True") + def _check_update_retired(self, node_obj, delta): + if 'retired' in delta: + if not node_obj.retired: + node_obj.retired_reason = None + elif node_obj.provision_state == states.AVAILABLE: + raise exception.InvalidState( + "Node %(node)s can not have 'retired' set in provision " + "state 'available', the current state is '%(state)s'" % + {'node': node_obj.uuid, 'state': node_obj.provision_state}) + + if ('retired_reason' in delta and node_obj.retired_reason and not + node_obj.retired): + raise exception.InvalidParameterValue( + "The retired_reason field can only be set when " + "retired is True") + @METRICS.timer('ConductorManager.update_node') # No need to add these since they are subclasses of InvalidParameterValue: # InterfaceNotFoundInEntrypoint @@ -187,6 +203,7 @@ class ConductorManager(base_manager.BaseConductorManager): node_obj.fault = None self._check_update_protected(node_obj, delta) + self._check_update_retired(node_obj, delta) # TODO(dtantsur): reconsider allowing changing some (but not all) # interfaces for active nodes in the future. @@ -1500,7 +1517,7 @@ class ConductorManager(base_manager.BaseConductorManager): tear_down_cleaning=False) LOG.info('Node %s cleaning complete', node.uuid) - event = 'manage' if manual_clean else 'done' + event = 'manage' if manual_clean or node.retired else 'done' # NOTE(rloo): No need to specify target prov. state; we're done task.process_event(event) @@ -1613,6 +1630,9 @@ class ConductorManager(base_manager.BaseConductorManager): and node.maintenance): raise exception.NodeInMaintenance(op=_('providing'), node=node.uuid) + if (node.retired): + raise exception.NodeIsRetired(op=_('providing'), + node=node.uuid) task.process_event( 'provide', callback=self._spawn_worker, diff --git a/ironic/db/api.py b/ironic/db/api.py index 9ad5e29799..33561296e1 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -56,6 +56,7 @@ class Connection(object, metaclass=abc.ABCMeta): :reserved: True | False :reserved_by_any_of: [conductor1, conductor2] :maintenance: True | False + :retired: True | False :chassis_uuid: uuid of chassis :driver: driver's name :provision_state: provision state of node diff --git a/ironic/db/sqlalchemy/alembic/versions/cd2c80feb331_add_node_retired_field.py b/ironic/db/sqlalchemy/alembic/versions/cd2c80feb331_add_node_retired_field.py new file mode 100644 index 0000000000..027e7659d9 --- /dev/null +++ b/ironic/db/sqlalchemy/alembic/versions/cd2c80feb331_add_node_retired_field.py @@ -0,0 +1,33 @@ +# 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. + +"""add nodes.retired field + +Revision ID: cd2c80feb331 +Revises: ce6c4b3cf5a2 +Create Date: 2020-01-16 12:51:13.866882 + +""" + +# revision identifiers, used by Alembic. +revision = 'cd2c80feb331' +down_revision = 'ce6c4b3cf5a2' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('nodes', sa.Column('retired', sa.Boolean(), nullable=True, + server_default=sa.false())) + op.add_column('nodes', sa.Column('retired_reason', sa.Text(), + nullable=True)) diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 8951eff3bb..3302764465 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -279,9 +279,10 @@ def _zip_matching(a, b, key): class Connection(api.Connection): """SqlAlchemy connection.""" - _NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'driver', - 'resource_class', 'provision_state', 'uuid', 'id', - 'fault', 'conductor_group', 'owner'} + _NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'retired', + 'driver', 'resource_class', 'provision_state', + 'uuid', 'id', 'fault', 'conductor_group', + 'owner'} _NODE_IN_QUERY_FIELDS = {'%s_in' % field: field for field in ('uuid', 'provision_state')} _NODE_NON_NULL_FILTERS = {'associated': 'instance_uuid', diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py index dc5127e3e3..863183bf53 100644 --- a/ironic/db/sqlalchemy/models.py +++ b/ironic/db/sqlalchemy/models.py @@ -193,6 +193,9 @@ class Node(Base): network_interface = Column(String(255), nullable=True) raid_interface = Column(String(255), nullable=True) rescue_interface = Column(String(255), nullable=True) + retired = Column(Boolean, nullable=True, default=False, + server_default=false()) + retired_reason = Column(Text, nullable=True) storage_interface = Column(String(255), nullable=True) power_interface = Column(String(255), nullable=True) vendor_interface = Column(String(255), nullable=True) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index c3d6e0104b..73a257586b 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -73,7 +73,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): # Version 1.30: Add owner field # Version 1.31: Add allocation_id field # Version 1.32: Add description field - VERSION = '1.32' + # Version 1.33: Add retired and retired_reason fields + VERSION = '1.33' dbapi = db_api.get_instance() @@ -159,6 +160,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): 'traits': object_fields.ObjectField('TraitList', nullable=True), 'owner': object_fields.StringField(nullable=True), 'description': object_fields.StringField(nullable=True), + 'retired': objects.fields.BooleanField(nullable=True), + 'retired_reason': object_fields.StringField(nullable=True), } def as_dict(self, secure=False): @@ -595,6 +598,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): should be set to None (or removed). Version 1.32: description was added. For versions prior to this, it should be set to None (or removed). + Version 1.33: retired was added. For versions prior to this, it + should be set to False (or removed). :param target_version: the desired version of the object :param remove_unavailable_fields: True to remove fields that are @@ -608,7 +613,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): fields = [('rescue_interface', 22), ('traits', 23), ('bios_interface', 24), ('fault', 25), ('automated_clean', 28), ('protected_reason', 29), - ('owner', 30), ('allocation_id', 31), ('description', 32)] + ('owner', 30), ('allocation_id', 31), ('description', 32), + ('retired_reason', 33)] for name, minor in fields: self._adjust_field_to_version(name, None, target_version, 1, minor, remove_unavailable_fields) @@ -622,6 +628,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat): self._convert_conductor_group_field(target_version, remove_unavailable_fields) + self._adjust_field_to_version('retired', False, target_version, + 1, 33, remove_unavailable_fields) + @base.IronicObjectRegistry.register class NodePayload(notification.NotificationPayloadBase): @@ -671,6 +680,8 @@ class NodePayload(notification.NotificationPayloadBase): 'provision_state': ('node', 'provision_state'), 'provision_updated_at': ('node', 'provision_updated_at'), 'resource_class': ('node', 'resource_class'), + 'retired': ('node', 'retired'), + 'retired_reason': ('node', 'retired_reason'), 'target_power_state': ('node', 'target_power_state'), 'target_provision_state': ('node', 'target_provision_state'), 'updated_at': ('node', 'updated_at'), @@ -692,7 +703,8 @@ class NodePayload(notification.NotificationPayloadBase): # Version 1.11: Add protected and protected_reason fields exposed via API. # Version 1.12: Add node owner field. # Version 1.13: Add description field. - VERSION = '1.13' + # Version 1.14: Add retired and retired_reason fields exposed via API. + VERSION = '1.14' fields = { 'clean_step': object_fields.FlexibleDictField(nullable=True), 'conductor_group': object_fields.StringField(nullable=True), @@ -730,6 +742,8 @@ class NodePayload(notification.NotificationPayloadBase): 'provision_state': object_fields.StringField(nullable=True), 'provision_updated_at': object_fields.DateTimeField(nullable=True), 'resource_class': object_fields.StringField(nullable=True), + 'retired': object_fields.BooleanField(nullable=True), + 'retired_reason': object_fields.StringField(nullable=True), 'target_power_state': object_fields.StringField(nullable=True), 'target_provision_state': object_fields.StringField(nullable=True), 'traits': object_fields.ListOfStringsField(nullable=True), @@ -776,7 +790,8 @@ class NodeSetPowerStatePayload(NodePayload): # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 - VERSION = '1.13' + # Version 1.14: Parent NodePayload version 1.14 + VERSION = '1.14' fields = { # "to_power" indicates the future target_power_state of the node. A @@ -830,7 +845,8 @@ class NodeCorrectedPowerStatePayload(NodePayload): # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 - VERSION = '1.13' + # Version 1.14: Parent NodePayload version 1.14 + VERSION = '1.14' fields = { 'from_power': object_fields.StringField(nullable=True) @@ -868,7 +884,8 @@ class NodeSetProvisionStatePayload(NodePayload): # Version 1.11: Parent NodePayload version 1.11 # Version 1.12: Parent NodePayload version 1.12 # Version 1.13: Parent NodePayload version 1.13 - VERSION = '1.13' + # Version 1.14: Parent NodePayload version 1.14 + VERSION = '1.14' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info')}) @@ -913,7 +930,8 @@ class NodeCRUDPayload(NodePayload): # Version 1.9: Parent NodePayload version 1.11 # Version 1.10: Parent NodePayload version 1.12 # Version 1.11: Parent NodePayload version 1.13 - VERSION = '1.11' + # Version 1.12: Parent NodePayload version 1.14 + VERSION = '1.12' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info'), diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index f1474f2251..7bf9e89d61 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -133,6 +133,8 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('protected', data['nodes'][0]) self.assertNotIn('protected_reason', data['nodes'][0]) self.assertNotIn('owner', data['nodes'][0]) + self.assertNotIn('retired', data['nodes'][0]) + self.assertNotIn('retired_reason', data['nodes'][0]) def test_get_one(self): node = obj_utils.create_test_node(self.context, @@ -353,6 +355,33 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.51'}) self.assertIsNone(data['description']) + def test_node_retired_hidden_in_lower_version(self): + self._test_node_field_hidden_in_lower_version('retired', + '1.60', '1.61') + + def test_node_retired_reason_hidden_in_lower_version(self): + self._test_node_field_hidden_in_lower_version('retired_reason', + '1.60', '1.61') + + def test_node_retired(self): + for value in (True, False): + node = obj_utils.create_test_node(self.context, retired=value, + provision_state='active', + uuid=uuidutils.generate_uuid()) + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.61'}) + self.assertIs(data['retired'], value) + self.assertIsNone(data['retired_reason']) + + def test_node_retired_with_reason(self): + node = obj_utils.create_test_node(self.context, retired=True, + provision_state='active', + retired_reason='warranty expired') + data = self.get_json('/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.61'}) + self.assertTrue(data['retired']) + self.assertEqual('warranty expired', data['retired_reason']) + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -568,6 +597,14 @@ class TestListNodes(test_api_base.BaseApiTest): headers={api_base.Version.string: '1.52'}) self.assertEqual(allocation.uuid, response['allocation_uuid']) + def test_get_retired_fields(self): + node = obj_utils.create_test_node(self.context, + retired=True) + response = self.get_json('/nodes/%s?fields=%s' % + (node.uuid, 'retired'), + headers={api_base.Version.string: '1.61'}) + self.assertIn('retired', response) + def test_detail(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) @@ -606,6 +643,8 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertNotIn('chassis_id', data['nodes'][0]) self.assertNotIn('allocation_id', data['nodes'][0]) self.assertIn('allocation_uuid', data['nodes'][0]) + self.assertIn('retired', data['nodes'][0]) + self.assertIn('retired_reason', data['nodes'][0]) def test_detail_using_query(self): node = obj_utils.create_test_node(self.context, @@ -641,6 +680,8 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertIn(field, data['nodes'][0]) # never expose the chassis_id self.assertNotIn('chassis_id', data['nodes'][0]) + self.assertIn('retired', data['nodes'][0]) + self.assertIn('retired_reason', data['nodes'][0]) def test_detail_query_false(self): obj_utils.create_test_node(self.context) @@ -3290,6 +3331,66 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual(http_client.BAD_REQUEST, response.status_code) self.assertTrue(response.json['error_message']) + def test_update_retired(self): + 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.61'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/retired', + 'value': True, + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_retired_with_reason(self): + 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.61'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/retired', + 'value': True, + 'op': 'replace'}, + {'path': '/retired_reason', + 'value': 'a better reason', + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_retired_reason(self): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state='active', + retired=True) + self.mock_update_node.return_value = node + headers = {api_base.Version.string: '1.61'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/retired_reason', + 'value': 'a better reason', + 'op': 'replace'}], + headers=headers) + self.assertEqual('application/json', response.content_type) + self.assertEqual(http_client.OK, response.status_code) + + def test_update_retired_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.60'} + response = self.patch_json('/nodes/%s' % node.uuid, + [{'path': '/retired', + '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) diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 50b7fcb62a..b2a1d832bc 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -553,6 +553,65 @@ class UpdateNodeTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): self.assertFalse(res['protected']) self.assertIsNone(res['protected_reason']) + def test_update_node_retired_set(self): + for state in ('active', 'rescue', 'manageable'): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state=state) + + node.retired = True + res = self.service.update_node(self.context, node) + self.assertTrue(res['retired']) + self.assertIsNone(res['retired_reason']) + + def test_update_node_retired_invalid_state(self): + # NOTE(arne_wiebalck): nodes in available cannot be 'retired'. + # This is to ensure backwards comaptibility. + node = obj_utils.create_test_node(self.context, + provision_state='available') + + node.retired = True + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.update_node, + self.context, + node) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.InvalidState, exc.exc_info[0]) + + res = objects.Node.get_by_uuid(self.context, node['uuid']) + self.assertFalse(res['retired']) + self.assertIsNone(res['retired_reason']) + + def test_update_node_retired_unset(self): + for state in ('active', 'manageable', 'rescue', 'rescue failed'): + node = obj_utils.create_test_node(self.context, + uuid=uuidutils.generate_uuid(), + provision_state=state, + retired=True, + retired_reason='EOL') + + # check that ManagerService.update_node actually updates the node + node.retired = False + res = self.service.update_node(self.context, node) + self.assertFalse(res['retired']) + self.assertIsNone(res['retired_reason']) + + def test_update_node_retired_reason_without_retired(self): + node = obj_utils.create_test_node(self.context, + provision_state='active') + + node.retired_reason = 'warranty expired' + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.update_node, + self.context, + node) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.InvalidParameterValue, exc.exc_info[0]) + + res = objects.Node.get_by_uuid(self.context, node['uuid']) + self.assertFalse(res['retired']) + self.assertIsNone(res['retired_reason']) + def test_update_node_already_locked(self): node = obj_utils.create_test_node(self.context, driver='fake-hardware', extra={'test': 'one'}) @@ -4253,7 +4312,8 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): @mock.patch('ironic.drivers.modules.fake.FakeDeploy.execute_clean_step', autospec=True) - def _do_next_clean_step_last_step_noop(self, mock_execute, manual=False): + def _do_next_clean_step_last_step_noop(self, mock_execute, manual=False, + retired=False): # Resume where last_step is the last cleaning step, should be noop tgt_prov_state = states.MANAGEABLE if manual else states.AVAILABLE info = {'clean_steps': self.clean_steps, @@ -4266,7 +4326,8 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): target_provision_state=tgt_prov_state, last_error=None, driver_internal_info=info, - clean_step=self.clean_steps[-1]) + clean_step=self.clean_steps[-1], + retired=retired) with task_manager.acquire( self.context, node.uuid, shared=False) as task: @@ -4275,6 +4336,10 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): self._stop_service() node.refresh() + # retired nodes move to manageable upon cleaning + if retired: + tgt_prov_state = states.MANAGEABLE + # Cleaning should be complete without calling additional steps self.assertEqual(tgt_prov_state, node.provision_state) self.assertEqual(states.NOSTATE, node.target_provision_state) @@ -4289,6 +4354,9 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): def test__do_next_clean_step_manual_last_step_noop(self): self._do_next_clean_step_last_step_noop(manual=True) + def test__do_next_clean_step_retired_last_step_change_tgt_state(self): + self._do_next_clean_step_last_step_noop(retired=True) + @mock.patch('ironic.drivers.modules.fake.FakePower.execute_clean_step', autospec=True) @mock.patch('ironic.drivers.modules.fake.FakeDeploy.execute_clean_step', diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py index 715ec15c3e..76b575711e 100644 --- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py +++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py @@ -969,6 +969,27 @@ class MigrationCheckersMixin(object): col_names = [column.name for column in allocations.c] self.assertIn('owner', col_names) + def _pre_upgrade_cd2c80feb331(self, engine): + data = { + 'node_uuid': uuidutils.generate_uuid(), + } + + nodes = db_utils.get_table(engine, 'nodes') + nodes.insert().execute({'uuid': data['node_uuid']}) + + return data + + def _check_cd2c80feb331(self, engine, data): + nodes = db_utils.get_table(engine, 'nodes') + col_names = [column.name for column in nodes.c] + self.assertIn('retired', col_names) + self.assertIn('retired_reason', col_names) + + node = nodes.select( + nodes.c.uuid == data['node_uuid']).execute().first() + self.assertFalse(node['retired']) + self.assertIsNone(node['retired_reason']) + def test_upgrade_and_version(self): with patch_with_engine(self.engine): self.migration_api.upgrade('head') diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py index 81a46d89a3..e8a4c45e3a 100644 --- a/ironic/tests/unit/db/utils.py +++ b/ironic/tests/unit/db/utils.py @@ -224,6 +224,9 @@ def get_test_node(**kw): 'owner': kw.get('owner', None), 'allocation_id': kw.get('allocation_id'), 'description': kw.get('description'), + 'retired': kw.get('retired', False), + 'retired_reason': kw.get('retired_reason', None), + } for iface in drivers_base.ALL_INTERFACES: diff --git a/ironic/tests/unit/objects/test_node.py b/ironic/tests/unit/objects/test_node.py index 1f0988c2a2..07d4cdae7f 100644 --- a/ironic/tests/unit/objects/test_node.py +++ b/ironic/tests/unit/objects/test_node.py @@ -886,6 +886,64 @@ class TestConvertToVersion(db_base.DbTestCase): self.assertEqual({'protected': False, 'protected_reason': None}, node.obj_get_changes()) + def test_retired_supported_missing(self): + # retired_interface not set, should be set to default. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + delattr(node, 'retired') + delattr(node, 'retired_reason') + node.obj_reset_changes() + node._convert_to_version("1.33") + self.assertFalse(node.retired) + self.assertIsNone(node.retired_reason) + self.assertEqual({'retired': False, 'retired_reason': None}, + node.obj_get_changes()) + + def test_retired_supported_set(self): + # retired set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.retired = True + node.retired_reason = 'a reason' + node.obj_reset_changes() + node._convert_to_version("1.33") + self.assertTrue(node.retired) + self.assertEqual('a reason', node.retired_reason) + self.assertEqual({}, node.obj_get_changes()) + + def test_retired_unsupported_missing(self): + # retired not set, no change required. + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + delattr(node, 'retired') + delattr(node, 'retired_reason') + node.obj_reset_changes() + node._convert_to_version("1.32") + self.assertNotIn('retired', node) + self.assertNotIn('retired_reason', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_retired_unsupported_set_remove(self): + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.retired = True + node.retired_reason = 'another reason' + node.obj_reset_changes() + node._convert_to_version("1.32") + self.assertNotIn('retired', node) + self.assertNotIn('retired_reason', node) + self.assertEqual({}, node.obj_get_changes()) + + def test_retired_unsupported_set_no_remove_non_default(self): + node = obj_utils.get_test_node(self.ctxt, **self.fake_node) + + node.retired = True + node.retired_reason = 'yet another reason' + node.obj_reset_changes() + node._convert_to_version("1.32", False) + self.assertIsNone(node.automated_clean) + self.assertEqual({'retired': False, 'retired_reason': None}, + node.obj_get_changes()) + def test_owner_supported_missing(self): # owner_interface not set, should be set to default. node = obj_utils.get_test_node(self.ctxt, **self.fake_node) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 33ae36d6d8..457c8be3cf 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -676,7 +676,7 @@ class TestObject(_LocalTest, _TestObject): # version bump. It is an MD5 hash of the object fields and remotable methods. # The fingerprint values should only be changed if there is a version bump. expected_object_fingerprints = { - 'Node': '1.32-525750e76f07b62142ed5297334b7832', + 'Node': '1.33-d6a8ba8dd3be3b2bbad0e0a5b9887aa8', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf', @@ -684,21 +684,21 @@ expected_object_fingerprints = { 'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', - 'NodePayload': '1.13-18a34d461ef7d5dbc1c3e5a55fcb867a', + 'NodePayload': '1.14-8b2dfc37d800f268d29a580ac034e2c6', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetPowerStatePayload': '1.13-4f96e52568e058e3fd6ffc9b0cf15764', + 'NodeSetPowerStatePayload': '1.14-dcd4d7911717ba323ab4c3297b92c31c', 'NodeCorrectedPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCorrectedPowerStatePayload': '1.13-929af354e7c3474520ce6162ee794717', + 'NodeCorrectedPowerStatePayload': '1.14-c7d20e953bbb9a1a4ce31ce22068e4bf', 'NodeSetProvisionStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetProvisionStatePayload': '1.13-fa15d2954961d8edcaba9d737a1cad91', + 'NodeSetProvisionStatePayload': '1.14-6d4145044a98c5cc80a40d69bbd98f61', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeCRUDPayload': '1.11-f1c6a6b099e8e28f55378c448c033de0', + 'NodeCRUDPayload': '1.12-3f63cdace5159785535049025ddf6a5c', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortCRUDPayload': '1.3-21235916ed54a91b2a122f59571194e7', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', diff --git a/releasenotes/notes/add_retirement_support-23c5fed7ce8f97d4.yaml b/releasenotes/notes/add_retirement_support-23c5fed7ce8f97d4.yaml new file mode 100644 index 0000000000..8165df1d9a --- /dev/null +++ b/releasenotes/notes/add_retirement_support-23c5fed7ce8f97d4.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Adds support for node retirement by adding a ``retired`` property + to the node. If set, a node moves upon automatic cleaning to + ``manageable`` (rather than ``available``). The new property can also + block the provide keyword, i.e. nodes cannot move from ``manageable`` + to ``available``. Furthermore, there is an additional optional property + ``retirement_reason`` to store the reason for the node's retirement. \ No newline at end of file