Support node retirement

This change adds support for node retirement: nodes can
have additional properties 'retired' and 'retired_reason'
which change the way the nodes (can) traverse the FSM
and which operations are allowed. In particular:
- retired nodes cannot move from manageable to available;
- upon instance deletion, retired nodes move to manageable
  (rather than available).

Story: #2005425
Task: #38142

Change-Id: I8113a44c28f62bf83f8e213aeb6704f96055d52b
This commit is contained in:
Arne Wiebalck 2020-01-16 12:58:56 +00:00
parent 1a1bdfa5c7
commit 3ecaadbb35
25 changed files with 433 additions and 36 deletions

View File

@ -426,6 +426,8 @@ Response
- description: n_description - description: n_description
- conductor: conductor - conductor: conductor
- allocation_uuid: allocation_uuid - allocation_uuid: allocation_uuid
- retired: retired
- retired_reason: retired_reason
**Example detailed list of Nodes:** **Example detailed list of Nodes:**
@ -469,6 +471,9 @@ only the specified set.
.. versionadded:: 1.52 .. versionadded:: 1.52
Introduced the ``allocation_uuid`` field. Introduced the ``allocation_uuid`` field.
.. versionadded:: 1.61
Introduced the ``retired`` and ``retired_reason`` fields.
Normal response codes: 200 Normal response codes: 200
Error codes: 400,403,404,406 Error codes: 400,403,404,406

View File

@ -1542,6 +1542,20 @@ response_driver_type:
in: body in: body
required: true required: true
type: string 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: standalone_ports_supported:
description: | description: |
Indicates whether ports that are members of this portgroup can be Indicates whether ports that are members of this portgroup can be

View File

@ -71,6 +71,8 @@
"rescue_interface": null, "rescue_interface": null,
"reservation": null, "reservation": null,
"resource_class": "bm-large", "resource_class": "bm-large",
"retired": false,
"retired_reason": null,
"states": [ "states": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states",

View File

@ -74,6 +74,8 @@
"rescue_interface": null, "rescue_interface": null,
"reservation": null, "reservation": null,
"resource_class": "bm-large", "resource_class": "bm-large",
"retired": false,
"retired_reason": null,
"states": [ "states": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states",

View File

@ -75,6 +75,8 @@
"rescue_interface": null, "rescue_interface": null,
"reservation": null, "reservation": null,
"resource_class": null, "resource_class": null,
"retired": false,
"retired_reason": null,
"states": [ "states": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states",

View File

@ -76,6 +76,8 @@
"rescue_interface": null, "rescue_interface": null,
"reservation": null, "reservation": null,
"resource_class": null, "resource_class": null,
"retired": false,
"retired_reason": null,
"states": [ "states": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states", "href": "http://127.0.0.1:6385/v1/nodes/6d85703a-565d-469a-96ce-30b6de53079d/states",
@ -178,6 +180,8 @@
"rescue_interface": "no-rescue", "rescue_interface": "no-rescue",
"reservation": null, "reservation": null,
"resource_class": null, "resource_class": null,
"retired": false,
"retired_reason": null,
"states": [ "states": [
{ {
"href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428/states", "href": "http://127.0.0.1:6385/v1/nodes/2b045129-a906-46af-bc1a-092b294b3428/states",

View File

@ -2,6 +2,15 @@
REST API Version History 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) 1.60 (Ussuri, master)
--------------------- ---------------------

View File

@ -1075,6 +1075,12 @@ class Node(base.APIBase):
allocation_uuid = wsme.wsattr(types.uuid, readonly=True) allocation_uuid = wsme.wsattr(types.uuid, readonly=True)
"""The UUID of the allocation this node belongs""" """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 # NOTE(deva): "conductor_affinity" shouldn't be presented on the
# API because it's an internal value. Don't add it here. # 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="", bios_interface=None, conductor_group="",
automated_clean=None, protected=False, automated_clean=None, protected=False,
protected_reason=None, owner=None, 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 # NOTE(matty_dubs): The chassis_uuid getter() is based on the
# _chassis_uuid variable: # _chassis_uuid variable:
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12' sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
@ -1605,8 +1612,8 @@ class NodesController(rest.RestController):
return filtered_nodes return filtered_nodes
def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated, def _get_nodes_collection(self, chassis_uuid, instance_uuid, associated,
maintenance, provision_state, marker, limit, maintenance, retired, provision_state, marker,
sort_key, sort_dir, driver=None, limit, sort_key, sort_dir, driver=None,
resource_class=None, resource_url=None, resource_class=None, resource_url=None,
fields=None, fault=None, conductor_group=None, fields=None, fault=None, conductor_group=None,
detail=None, conductor=None, owner=None, detail=None, conductor=None, owner=None,
@ -1654,6 +1661,7 @@ class NodesController(rest.RestController):
'conductor_group': conductor_group, 'conductor_group': conductor_group,
'owner': owner, 'owner': owner,
'description_contains': description_contains, 'description_contains': description_contains,
'retired': retired,
} }
filters = {} filters = {}
for key, value in possible_filters.items(): for key, value in possible_filters.items():
@ -1673,6 +1681,8 @@ class NodesController(rest.RestController):
parameters['associated'] = associated parameters['associated'] = associated
if maintenance: if maintenance:
parameters['maintenance'] = maintenance parameters['maintenance'] = maintenance
if retired:
parameters['retired'] = retired
if detail is not None: if detail is not None:
parameters['detail'] = detail parameters['detail'] = detail
@ -1773,14 +1783,14 @@ class NodesController(rest.RestController):
@METRICS.timer('NodesController.get_all') @METRICS.timer('NodesController.get_all')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @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.listtype, str,
str, str, types.boolean, str, str, str, types.boolean, str,
str, str) str, str)
def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None, def get_all(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, retired=None, provision_state=None,
limit=None, sort_key='id', sort_dir='asc', driver=None, marker=None, limit=None, sort_key='id', sort_dir='asc',
fields=None, resource_class=None, fault=None, driver=None, fields=None, resource_class=None, fault=None,
conductor_group=None, detail=None, conductor=None, conductor_group=None, detail=None, conductor=None,
owner=None, description_contains=None): owner=None, description_contains=None):
"""Retrieve a list of nodes. """Retrieve a list of nodes.
@ -1795,6 +1805,8 @@ class NodesController(rest.RestController):
:param maintenance: Optional boolean value that indicates whether :param maintenance: Optional boolean value that indicates whether
to get nodes in maintenance mode ("True"), or not to get nodes in maintenance mode ("True"), or not
in maintenance mode ("False"). 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 :param provision_state: Optional string value to get only nodes in
that provision state. that provision state.
:param marker: pagination marker for large data sets. :param marker: pagination marker for large data sets.
@ -1839,7 +1851,7 @@ class NodesController(rest.RestController):
extra_args = {'description_contains': description_contains} extra_args = {'description_contains': description_contains}
return self._get_nodes_collection(chassis_uuid, instance_uuid, return self._get_nodes_collection(chassis_uuid, instance_uuid,
associated, maintenance, associated, maintenance, retired,
provision_state, marker, provision_state, marker,
limit, sort_key, sort_dir, limit, sort_key, sort_dir,
driver=driver, driver=driver,
@ -1853,14 +1865,15 @@ class NodesController(rest.RestController):
@METRICS.timer('NodesController.detail') @METRICS.timer('NodesController.detail')
@expose.expose(NodeCollection, types.uuid, types.uuid, types.boolean, @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,
str, str, str, str) str, str, str, str)
def detail(self, chassis_uuid=None, instance_uuid=None, associated=None, def detail(self, chassis_uuid=None, instance_uuid=None, associated=None,
maintenance=None, provision_state=None, marker=None, maintenance=None, retired=None, provision_state=None,
limit=None, sort_key='id', sort_dir='asc', driver=None, marker=None, limit=None, sort_key='id', sort_dir='asc',
resource_class=None, fault=None, conductor_group=None, driver=None, resource_class=None, fault=None,
conductor=None, owner=None, description_contains=None): conductor_group=None, conductor=None, owner=None,
description_contains=None):
"""Retrieve a list of nodes with detail. """Retrieve a list of nodes with detail.
:param chassis_uuid: Optional UUID of a chassis, to get only nodes for :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 :param maintenance: Optional boolean value that indicates whether
to get nodes in maintenance mode ("True"), or not to get nodes in maintenance mode ("True"), or not
in maintenance mode ("False"). 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 :param provision_state: Optional string value to get only nodes in
that provision state. that provision state.
:param marker: pagination marker for large data sets. :param marker: pagination marker for large data sets.
@ -1914,7 +1929,7 @@ class NodesController(rest.RestController):
resource_url = '/'.join(['nodes', 'detail']) resource_url = '/'.join(['nodes', 'detail'])
extra_args = {'description_contains': description_contains} extra_args = {'description_contains': description_contains}
return self._get_nodes_collection(chassis_uuid, instance_uuid, return self._get_nodes_collection(chassis_uuid, instance_uuid,
associated, maintenance, associated, maintenance, retired,
provision_state, marker, provision_state, marker,
limit, sort_key, sort_dir, limit, sort_key, sort_dir,
driver=driver, driver=driver,

View File

@ -487,6 +487,8 @@ VERSIONED_FIELDS = {
'description': versions.MINOR_51_NODE_DESCRIPTION, 'description': versions.MINOR_51_NODE_DESCRIPTION,
'allocation_uuid': versions.MINOR_52_ALLOCATION, 'allocation_uuid': versions.MINOR_52_ALLOCATION,
'events': versions.MINOR_54_EVENTS, 'events': versions.MINOR_54_EVENTS,
'retired': versions.MINOR_61_NODE_RETIRED,
'retired_reason': versions.MINOR_61_NODE_RETIRED,
} }
for field in V31_FIELDS: for field in V31_FIELDS:

View File

@ -160,6 +160,7 @@ MINOR_57_ALLOCATION_UPDATE = 57
MINOR_58_ALLOCATION_BACKFILL = 58 MINOR_58_ALLOCATION_BACKFILL = 58
MINOR_59_CONFIGDRIVE_VENDOR_DATA = 59 MINOR_59_CONFIGDRIVE_VENDOR_DATA = 59
MINOR_60_ALLOCATION_OWNER = 60 MINOR_60_ALLOCATION_OWNER = 60
MINOR_61_NODE_RETIRED = 61
# When adding another version, update: # When adding another version, update:
# - MINOR_MAX_VERSION # - MINOR_MAX_VERSION
@ -167,7 +168,7 @@ MINOR_60_ALLOCATION_OWNER = 60
# explanation of what changed in the new version # explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api'] # - 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 # String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -710,3 +710,8 @@ class IBMCConnectionError(IBMCError):
class ClientSideError(wsme.exc.ClientSideError): class ClientSideError(wsme.exc.ClientSideError):
pass pass
class NodeIsRetired(Invalid):
_msg_fmt = _("The %(op)s operation can't be performed on node "
"%(node)s because it is retired.")

View File

@ -197,11 +197,11 @@ RELEASE_MAPPING = {
} }
}, },
'master': { 'master': {
'api': '1.60', 'api': '1.61',
'rpc': '1.48', 'rpc': '1.48',
'objects': { 'objects': {
'Allocation': ['1.1'], 'Allocation': ['1.1'],
'Node': ['1.32'], 'Node': ['1.33', '1.32'],
'Conductor': ['1.3'], 'Conductor': ['1.3'],
'Chassis': ['1.3'], 'Chassis': ['1.3'],
'DeployTemplate': ['1.1'], 'DeployTemplate': ['1.1'],

View File

@ -151,6 +151,22 @@ class ConductorManager(base_manager.BaseConductorManager):
"The protected_reason field can only be set when " "The protected_reason field can only be set when "
"protected is True") "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') @METRICS.timer('ConductorManager.update_node')
# No need to add these since they are subclasses of InvalidParameterValue: # No need to add these since they are subclasses of InvalidParameterValue:
# InterfaceNotFoundInEntrypoint # InterfaceNotFoundInEntrypoint
@ -187,6 +203,7 @@ class ConductorManager(base_manager.BaseConductorManager):
node_obj.fault = None node_obj.fault = None
self._check_update_protected(node_obj, delta) self._check_update_protected(node_obj, delta)
self._check_update_retired(node_obj, delta)
# TODO(dtantsur): reconsider allowing changing some (but not all) # TODO(dtantsur): reconsider allowing changing some (but not all)
# interfaces for active nodes in the future. # interfaces for active nodes in the future.
@ -1500,7 +1517,7 @@ class ConductorManager(base_manager.BaseConductorManager):
tear_down_cleaning=False) tear_down_cleaning=False)
LOG.info('Node %s cleaning complete', node.uuid) 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 # NOTE(rloo): No need to specify target prov. state; we're done
task.process_event(event) task.process_event(event)
@ -1613,6 +1630,9 @@ class ConductorManager(base_manager.BaseConductorManager):
and node.maintenance): and node.maintenance):
raise exception.NodeInMaintenance(op=_('providing'), raise exception.NodeInMaintenance(op=_('providing'),
node=node.uuid) node=node.uuid)
if (node.retired):
raise exception.NodeIsRetired(op=_('providing'),
node=node.uuid)
task.process_event( task.process_event(
'provide', 'provide',
callback=self._spawn_worker, callback=self._spawn_worker,

View File

@ -56,6 +56,7 @@ class Connection(object, metaclass=abc.ABCMeta):
:reserved: True | False :reserved: True | False
:reserved_by_any_of: [conductor1, conductor2] :reserved_by_any_of: [conductor1, conductor2]
:maintenance: True | False :maintenance: True | False
:retired: True | False
:chassis_uuid: uuid of chassis :chassis_uuid: uuid of chassis
:driver: driver's name :driver: driver's name
:provision_state: provision state of node :provision_state: provision state of node

View File

@ -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))

View File

@ -279,9 +279,10 @@ def _zip_matching(a, b, key):
class Connection(api.Connection): class Connection(api.Connection):
"""SqlAlchemy connection.""" """SqlAlchemy connection."""
_NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'driver', _NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'retired',
'resource_class', 'provision_state', 'uuid', 'id', 'driver', 'resource_class', 'provision_state',
'fault', 'conductor_group', 'owner'} 'uuid', 'id', 'fault', 'conductor_group',
'owner'}
_NODE_IN_QUERY_FIELDS = {'%s_in' % field: field _NODE_IN_QUERY_FIELDS = {'%s_in' % field: field
for field in ('uuid', 'provision_state')} for field in ('uuid', 'provision_state')}
_NODE_NON_NULL_FILTERS = {'associated': 'instance_uuid', _NODE_NON_NULL_FILTERS = {'associated': 'instance_uuid',

View File

@ -193,6 +193,9 @@ class Node(Base):
network_interface = Column(String(255), nullable=True) network_interface = Column(String(255), nullable=True)
raid_interface = Column(String(255), nullable=True) raid_interface = Column(String(255), nullable=True)
rescue_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) storage_interface = Column(String(255), nullable=True)
power_interface = Column(String(255), nullable=True) power_interface = Column(String(255), nullable=True)
vendor_interface = Column(String(255), nullable=True) vendor_interface = Column(String(255), nullable=True)

View File

@ -73,7 +73,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# Version 1.30: Add owner field # Version 1.30: Add owner field
# Version 1.31: Add allocation_id field # Version 1.31: Add allocation_id field
# Version 1.32: Add description 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() dbapi = db_api.get_instance()
@ -159,6 +160,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
'traits': object_fields.ObjectField('TraitList', nullable=True), 'traits': object_fields.ObjectField('TraitList', nullable=True),
'owner': object_fields.StringField(nullable=True), 'owner': object_fields.StringField(nullable=True),
'description': 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): def as_dict(self, secure=False):
@ -595,6 +598,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
should be set to None (or removed). should be set to None (or removed).
Version 1.32: description was added. For versions prior to this, it Version 1.32: description was added. For versions prior to this, it
should be set to None (or removed). 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 target_version: the desired version of the object
:param remove_unavailable_fields: True to remove fields that are :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), fields = [('rescue_interface', 22), ('traits', 23),
('bios_interface', 24), ('fault', 25), ('bios_interface', 24), ('fault', 25),
('automated_clean', 28), ('protected_reason', 29), ('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: for name, minor in fields:
self._adjust_field_to_version(name, None, target_version, self._adjust_field_to_version(name, None, target_version,
1, minor, remove_unavailable_fields) 1, minor, remove_unavailable_fields)
@ -622,6 +628,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
self._convert_conductor_group_field(target_version, self._convert_conductor_group_field(target_version,
remove_unavailable_fields) remove_unavailable_fields)
self._adjust_field_to_version('retired', False, target_version,
1, 33, remove_unavailable_fields)
@base.IronicObjectRegistry.register @base.IronicObjectRegistry.register
class NodePayload(notification.NotificationPayloadBase): class NodePayload(notification.NotificationPayloadBase):
@ -671,6 +680,8 @@ class NodePayload(notification.NotificationPayloadBase):
'provision_state': ('node', 'provision_state'), 'provision_state': ('node', 'provision_state'),
'provision_updated_at': ('node', 'provision_updated_at'), 'provision_updated_at': ('node', 'provision_updated_at'),
'resource_class': ('node', 'resource_class'), 'resource_class': ('node', 'resource_class'),
'retired': ('node', 'retired'),
'retired_reason': ('node', 'retired_reason'),
'target_power_state': ('node', 'target_power_state'), 'target_power_state': ('node', 'target_power_state'),
'target_provision_state': ('node', 'target_provision_state'), 'target_provision_state': ('node', 'target_provision_state'),
'updated_at': ('node', 'updated_at'), '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.11: Add protected and protected_reason fields exposed via API.
# Version 1.12: Add node owner field. # Version 1.12: Add node owner field.
# Version 1.13: Add description 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 = { fields = {
'clean_step': object_fields.FlexibleDictField(nullable=True), 'clean_step': object_fields.FlexibleDictField(nullable=True),
'conductor_group': object_fields.StringField(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_state': object_fields.StringField(nullable=True),
'provision_updated_at': object_fields.DateTimeField(nullable=True), 'provision_updated_at': object_fields.DateTimeField(nullable=True),
'resource_class': object_fields.StringField(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_power_state': object_fields.StringField(nullable=True),
'target_provision_state': object_fields.StringField(nullable=True), 'target_provision_state': object_fields.StringField(nullable=True),
'traits': object_fields.ListOfStringsField(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.11: Parent NodePayload version 1.11
# Version 1.12: Parent NodePayload version 1.12 # Version 1.12: Parent NodePayload version 1.12
# Version 1.13: Parent NodePayload version 1.13 # Version 1.13: Parent NodePayload version 1.13
VERSION = '1.13' # Version 1.14: Parent NodePayload version 1.14
VERSION = '1.14'
fields = { fields = {
# "to_power" indicates the future target_power_state of the node. A # "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.11: Parent NodePayload version 1.11
# Version 1.12: Parent NodePayload version 1.12 # Version 1.12: Parent NodePayload version 1.12
# Version 1.13: Parent NodePayload version 1.13 # Version 1.13: Parent NodePayload version 1.13
VERSION = '1.13' # Version 1.14: Parent NodePayload version 1.14
VERSION = '1.14'
fields = { fields = {
'from_power': object_fields.StringField(nullable=True) 'from_power': object_fields.StringField(nullable=True)
@ -868,7 +884,8 @@ class NodeSetProvisionStatePayload(NodePayload):
# Version 1.11: Parent NodePayload version 1.11 # Version 1.11: Parent NodePayload version 1.11
# Version 1.12: Parent NodePayload version 1.12 # Version 1.12: Parent NodePayload version 1.12
# Version 1.13: Parent NodePayload version 1.13 # 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, SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info')}) **{'instance_info': ('node', 'instance_info')})
@ -913,7 +930,8 @@ class NodeCRUDPayload(NodePayload):
# Version 1.9: Parent NodePayload version 1.11 # Version 1.9: Parent NodePayload version 1.11
# Version 1.10: Parent NodePayload version 1.12 # Version 1.10: Parent NodePayload version 1.12
# Version 1.11: Parent NodePayload version 1.13 # 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, SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info'), **{'instance_info': ('node', 'instance_info'),

View File

@ -133,6 +133,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('protected', data['nodes'][0]) self.assertNotIn('protected', data['nodes'][0])
self.assertNotIn('protected_reason', data['nodes'][0]) self.assertNotIn('protected_reason', data['nodes'][0])
self.assertNotIn('owner', 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): def test_get_one(self):
node = obj_utils.create_test_node(self.context, 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'}) headers={api_base.Version.string: '1.51'})
self.assertIsNone(data['description']) 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): def test_get_one_custom_fields(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id) chassis_id=self.chassis.id)
@ -568,6 +597,14 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.52'}) headers={api_base.Version.string: '1.52'})
self.assertEqual(allocation.uuid, response['allocation_uuid']) 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): def test_detail(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id) chassis_id=self.chassis.id)
@ -606,6 +643,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('chassis_id', data['nodes'][0]) self.assertNotIn('chassis_id', data['nodes'][0])
self.assertNotIn('allocation_id', data['nodes'][0]) self.assertNotIn('allocation_id', data['nodes'][0])
self.assertIn('allocation_uuid', 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): def test_detail_using_query(self):
node = obj_utils.create_test_node(self.context, node = obj_utils.create_test_node(self.context,
@ -641,6 +680,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn(field, data['nodes'][0]) self.assertIn(field, data['nodes'][0])
# never expose the chassis_id # never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0]) 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): def test_detail_query_false(self):
obj_utils.create_test_node(self.context) 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.assertEqual(http_client.BAD_REQUEST, response.status_code)
self.assertTrue(response.json['error_message']) 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): def _create_node_locally(node):
driver_factory.check_and_update_node_interfaces(node) driver_factory.check_and_update_node_interfaces(node)

View File

@ -553,6 +553,65 @@ class UpdateNodeTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
self.assertFalse(res['protected']) self.assertFalse(res['protected'])
self.assertIsNone(res['protected_reason']) 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): def test_update_node_already_locked(self):
node = obj_utils.create_test_node(self.context, driver='fake-hardware', node = obj_utils.create_test_node(self.context, driver='fake-hardware',
extra={'test': 'one'}) 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', @mock.patch('ironic.drivers.modules.fake.FakeDeploy.execute_clean_step',
autospec=True) 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 # Resume where last_step is the last cleaning step, should be noop
tgt_prov_state = states.MANAGEABLE if manual else states.AVAILABLE tgt_prov_state = states.MANAGEABLE if manual else states.AVAILABLE
info = {'clean_steps': self.clean_steps, info = {'clean_steps': self.clean_steps,
@ -4266,7 +4326,8 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
target_provision_state=tgt_prov_state, target_provision_state=tgt_prov_state,
last_error=None, last_error=None,
driver_internal_info=info, driver_internal_info=info,
clean_step=self.clean_steps[-1]) clean_step=self.clean_steps[-1],
retired=retired)
with task_manager.acquire( with task_manager.acquire(
self.context, node.uuid, shared=False) as task: self.context, node.uuid, shared=False) as task:
@ -4275,6 +4336,10 @@ class DoNodeCleanTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
self._stop_service() self._stop_service()
node.refresh() node.refresh()
# retired nodes move to manageable upon cleaning
if retired:
tgt_prov_state = states.MANAGEABLE
# Cleaning should be complete without calling additional steps # Cleaning should be complete without calling additional steps
self.assertEqual(tgt_prov_state, node.provision_state) self.assertEqual(tgt_prov_state, node.provision_state)
self.assertEqual(states.NOSTATE, node.target_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): def test__do_next_clean_step_manual_last_step_noop(self):
self._do_next_clean_step_last_step_noop(manual=True) 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', @mock.patch('ironic.drivers.modules.fake.FakePower.execute_clean_step',
autospec=True) autospec=True)
@mock.patch('ironic.drivers.modules.fake.FakeDeploy.execute_clean_step', @mock.patch('ironic.drivers.modules.fake.FakeDeploy.execute_clean_step',

View File

@ -969,6 +969,27 @@ class MigrationCheckersMixin(object):
col_names = [column.name for column in allocations.c] col_names = [column.name for column in allocations.c]
self.assertIn('owner', col_names) 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): def test_upgrade_and_version(self):
with patch_with_engine(self.engine): with patch_with_engine(self.engine):
self.migration_api.upgrade('head') self.migration_api.upgrade('head')

View File

@ -224,6 +224,9 @@ def get_test_node(**kw):
'owner': kw.get('owner', None), 'owner': kw.get('owner', None),
'allocation_id': kw.get('allocation_id'), 'allocation_id': kw.get('allocation_id'),
'description': kw.get('description'), 'description': kw.get('description'),
'retired': kw.get('retired', False),
'retired_reason': kw.get('retired_reason', None),
} }
for iface in drivers_base.ALL_INTERFACES: for iface in drivers_base.ALL_INTERFACES:

View File

@ -886,6 +886,64 @@ class TestConvertToVersion(db_base.DbTestCase):
self.assertEqual({'protected': False, 'protected_reason': None}, self.assertEqual({'protected': False, 'protected_reason': None},
node.obj_get_changes()) 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): def test_owner_supported_missing(self):
# owner_interface not set, should be set to default. # owner_interface not set, should be set to default.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node) node = obj_utils.get_test_node(self.ctxt, **self.fake_node)

View File

@ -676,7 +676,7 @@ class TestObject(_LocalTest, _TestObject):
# version bump. It is an MD5 hash of the object fields and remotable methods. # 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. # The fingerprint values should only be changed if there is a version bump.
expected_object_fingerprints = { expected_object_fingerprints = {
'Node': '1.32-525750e76f07b62142ed5297334b7832', 'Node': '1.33-d6a8ba8dd3be3b2bbad0e0a5b9887aa8',
'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6', 'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6',
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905', 'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf', 'Port': '1.9-0cb9202a4ec442e8c0d87a324155eaaf',
@ -684,21 +684,21 @@ expected_object_fingerprints = {
'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a', 'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a',
'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370',
'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d',
'NodePayload': '1.13-18a34d461ef7d5dbc1c3e5a55fcb867a', 'NodePayload': '1.14-8b2dfc37d800f268d29a580ac034e2c6',
'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeSetPowerStatePayload': '1.13-4f96e52568e058e3fd6ffc9b0cf15764', 'NodeSetPowerStatePayload': '1.14-dcd4d7911717ba323ab4c3297b92c31c',
'NodeCorrectedPowerStateNotification': 'NodeCorrectedPowerStateNotification':
'1.0-59acc533c11d306f149846f922739c15', '1.0-59acc533c11d306f149846f922739c15',
'NodeCorrectedPowerStatePayload': '1.13-929af354e7c3474520ce6162ee794717', 'NodeCorrectedPowerStatePayload': '1.14-c7d20e953bbb9a1a4ce31ce22068e4bf',
'NodeSetProvisionStateNotification': 'NodeSetProvisionStateNotification':
'1.0-59acc533c11d306f149846f922739c15', '1.0-59acc533c11d306f149846f922739c15',
'NodeSetProvisionStatePayload': '1.13-fa15d2954961d8edcaba9d737a1cad91', 'NodeSetProvisionStatePayload': '1.14-6d4145044a98c5cc80a40d69bbd98f61',
'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97',
'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e',
'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202',
'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeCRUDPayload': '1.11-f1c6a6b099e8e28f55378c448c033de0', 'NodeCRUDPayload': '1.12-3f63cdace5159785535049025ddf6a5c',
'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'PortCRUDPayload': '1.3-21235916ed54a91b2a122f59571194e7', 'PortCRUDPayload': '1.3-21235916ed54a91b2a122f59571194e7',
'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15', 'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15',

View File

@ -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.