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:
parent
1a1bdfa5c7
commit
3ecaadbb35
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
---------------------
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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.")
|
||||
|
@ -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'],
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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))
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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.
|
Loading…
Reference in New Issue
Block a user