Support for protecting nodes from undeploying and rebuilding

When handling the "pet" case, some nodes may be critical for the deployment.
For example, in an OpenStack installer like TripleO you may want to make
sure your controllers are not removed by an incorrect operation.

This changes introduces a new field "protected" on nodes. When it is
set to True, the "deleted" and "rebuild" provisioning actions fail with
HTTP 403.  Deleting such nodes is also not possible.

Also adds "protected_reason" for the operators to specify the reason
a node is protected.

Story: #2003869
Task: #26706
Change-Id: I1950bf6dd65b6596cae69d431ef288e578a89d6e
This commit is contained in:
Dmitry Tantsur 2018-10-18 15:12:06 +02:00
parent 0ea86814ad
commit 68d62f2bee
23 changed files with 485 additions and 25 deletions

View File

@ -139,7 +139,7 @@ and any defaults added for non-specified fields. Most fields default to "null"
or "".
The list and example below are representative of the response as of API
microversion 1.46.
microversion 1.48.
.. rest_parameters:: parameters.yaml
@ -186,6 +186,8 @@ microversion 1.46.
- vendor_interface: vendor_interface
- volume: n_volume
- conductor_group: conductor_group
- protected: protected
- protected_reason: protected_reason
**Example JSON representation of a Node:**
@ -302,6 +304,9 @@ Nova instance, eg. with a request to ``v1/nodes/detail?instance_uuid={NOVA INSTA
.. versionadded:: 1.46
Introduced the ``conductor_group`` field.
.. versionadded:: 1.48
Introduced the ``protected`` and ``protected_reason`` fields.
Normal response codes: 200
Error codes: 400,403,406
@ -372,6 +377,8 @@ Response
- vendor_interface: vendor_interface
- volume: n_volume
- conductor_group: conductor_group
- protected: protected
- protected_reason: protected_reason
**Example detailed list of Nodes:**
@ -400,6 +407,9 @@ only the specified set.
.. versionadded:: 1.46
Introduced the ``conductor_group`` field.
.. versionadded:: 1.48
Introduced the ``protected`` and ``protected_reason`` fields.
Normal response codes: 200
Error codes: 400,403,404,406
@ -460,6 +470,8 @@ Response
- vendor_interface: vendor_interface
- volume: n_volume
- conductor_group: conductor_group
- protected: protected
- protected_reason: protected_reason
**Example JSON representation of a Node:**
@ -546,6 +558,8 @@ Response
- vendor_interface: vendor_interface
- volume: n_volume
- conductor_group: conductor_group
- protected: protected
- protected_reason: protected_reason
**Example JSON representation of a Node:**

View File

@ -987,6 +987,18 @@ properties:
in: body
required: true
type: array
protected:
description: |
Whether the node is protected from undeploying, rebuilding and deletion.
in: body
required: false
type: boolean
protected_reason:
description: |
The reason the node is marked as protected.
in: body
required: false
type: string
provision_state:
description: |
The current provisioning state of this Node.

View File

@ -59,6 +59,8 @@
"power_interface": null,
"power_state": null,
"properties": {},
"protected": false,
"protected_reason": null,
"provision_state": "enroll",
"provision_updated_at": null,
"raid_config": {},

View File

@ -61,6 +61,8 @@
"power_interface": null,
"power_state": "power off",
"properties": {},
"protected": false,
"protected_reason": null,
"provision_state": "available",
"provision_updated_at": "2016-08-18T22:28:49.946416+00:00",
"raid_config": {},

View File

@ -63,6 +63,8 @@
"power_interface": null,
"power_state": "power off",
"properties": {},
"protected": false,
"protected_reason": null,
"provision_state": "available",
"provision_updated_at": "2016-08-18T22:28:49.946416+00:00",
"raid_config": {},

View File

@ -63,6 +63,8 @@
"power_interface": null,
"power_state": "power off",
"properties": {},
"protected": false,
"protected_reason": null,
"provision_state": "available",
"provision_updated_at": "2016-08-18T22:28:49.946416+00:00",
"raid_config": {},
@ -160,6 +162,8 @@
"power_interface": "ipmitool",
"power_state": null,
"properties": {},
"protected": false,
"protected_reason": null,
"provision_state": "enroll",
"provision_updated_at": null,
"raid_config": {},

View File

@ -2,6 +2,13 @@
REST API Version History
========================
1.48 (Stein, master)
--------------------
Added ``protected`` field to the node object to allow protecting deployed nodes
from undeploying, rebuilding or deletion. Also added ``protected_reason``
to specify the reason of making the node protected.
1.47 (Stein, master)
--------------------

View File

@ -1066,6 +1066,12 @@ class Node(base.APIBase):
automated_clean = types.boolean
"""Indicates whether the node will perform automated clean or not."""
protected = types.boolean
"""Indicates whether the node is protected from undeploying/rebuilding."""
protected_reason = wsme.wsattr(wtypes.text)
"""Indicates reason for protecting the node."""
# NOTE(deva): "conductor_affinity" shouldn't be presented on the
# API because it's an internal value. Don't add it here.
@ -1253,7 +1259,8 @@ class Node(base.APIBase):
raid_interface=None, vendor_interface=None,
storage_interface=None, traits=[], rescue_interface=None,
bios_interface=None, conductor_group="",
automated_clean=None)
automated_clean=None, protected=False,
protected_reason=None)
# NOTE(matty_dubs): The chassis_uuid getter() is based on the
# _chassis_uuid variable:
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
@ -1913,6 +1920,12 @@ class NodesController(rest.RestController):
"be set via the node traits API.")
raise exception.Invalid(msg)
if (node.protected is not wtypes.Unset or
node.protected_reason is not wtypes.Unset):
msg = _("Cannot specify protected or protected_reason on node "
"creation. These fields can only be set for active nodes")
raise exception.Invalid(msg)
# NOTE(deva): get_topic_for checks if node.driver is in the hash ring
# and raises NoValidHost if it is not.
# We need to ensure that node has a UUID before it can

View File

@ -376,6 +376,8 @@ VERSIONED_FIELDS = {
'deploy_step': versions.MINOR_44_NODE_DEPLOY_STEP,
'conductor_group': versions.MINOR_46_NODE_CONDUCTOR_GROUP,
'automated_clean': versions.MINOR_47_NODE_AUTOMATED_CLEAN,
'protected': versions.MINOR_48_NODE_PROTECTED,
'protected_reason': versions.MINOR_48_NODE_PROTECTED,
}
for field in V31_FIELDS:

View File

@ -85,6 +85,7 @@ BASE_VERSION = 1
# v1.45: reset_interfaces parameter to node's PATCH
# v1.46: Add conductor_group to the node object.
# v1.47: Add automated_clean to the node object.
# v1.48: Add protected to the node object.
MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1
@ -134,6 +135,7 @@ MINOR_44_NODE_DEPLOY_STEP = 44
MINOR_45_RESET_INTERFACES = 45
MINOR_46_NODE_CONDUCTOR_GROUP = 46
MINOR_47_NODE_AUTOMATED_CLEAN = 47
MINOR_48_NODE_PROTECTED = 48
# When adding another version, update:
# - MINOR_MAX_VERSION
@ -141,7 +143,7 @@ MINOR_47_NODE_AUTOMATED_CLEAN = 47
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_47_NODE_AUTOMATED_CLEAN
MINOR_MAX_VERSION = MINOR_48_NODE_PROTECTED
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -786,3 +786,8 @@ class DatabaseVersionTooOld(IronicException):
class AgentConnectionFailed(IronicException):
_msg_fmt = _("Connection to agent failed: %(reason)s")
class NodeProtected(HTTPForbidden):
_msg_fmt = _("Node %(node)s is protected and cannot be undeployed, "
"rebuilt or deleted")

View File

@ -131,10 +131,10 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.47',
'api': '1.48',
'rpc': '1.47',
'objects': {
'Node': ['1.28'],
'Node': ['1.29', '1.28'],
'Conductor': ['1.3'],
'Chassis': ['1.3'],
'Port': ['1.8'],

View File

@ -135,6 +135,24 @@ class ConductorManager(base_manager.BaseConductorManager):
node_obj.create()
return node_obj
def _check_update_protected(self, node_obj, delta):
if 'protected' in delta:
if not node_obj.protected:
node_obj.protected_reason = None
elif node_obj.provision_state not in (states.ACTIVE,
states.RESCUE):
raise exception.InvalidState(
"Node %(node)s can only be made protected in provision "
"states 'active' or 'rescue', the current state is "
"'%(state)s'" %
{'node': node_obj.uuid, 'state': node_obj.provision_state})
if ('protected_reason' in delta and node_obj.protected_reason and not
node_obj.protected):
raise exception.InvalidParameterValue(
"The protected_reason field can only be set when "
"protected is True")
@METRICS.timer('ConductorManager.update_node')
# No need to add these since they are subclasses of InvalidParameterValue:
# InterfaceNotFoundInEntrypoint
@ -170,6 +188,8 @@ class ConductorManager(base_manager.BaseConductorManager):
node_obj.maintenance_reason = None
node_obj.fault = None
self._check_update_protected(node_obj, delta)
# TODO(dtantsur): reconsider allowing changing some (but not all)
# interfaces for active nodes in the future.
# NOTE(kaifeng): INSPECTING is allowed to keep backwards
@ -736,7 +756,8 @@ class ConductorManager(base_manager.BaseConductorManager):
exception.NodeLocked,
exception.NodeInMaintenance,
exception.InstanceDeployFailure,
exception.InvalidStateRequested)
exception.InvalidStateRequested,
exception.NodeProtected)
def do_node_deploy(self, context, node_id, rebuild=False,
configdrive=None):
"""RPC method to initiate deployment to a node.
@ -758,7 +779,7 @@ class ConductorManager(base_manager.BaseConductorManager):
async task.
:raises: InvalidStateRequested when the requested state is not a valid
target from the current state.
:raises: NodeProtected if the node is protected.
"""
LOG.debug("RPC do_node_deploy called for node %s.", node_id)
@ -774,6 +795,9 @@ class ConductorManager(base_manager.BaseConductorManager):
node=node.uuid)
if rebuild:
if node.protected:
raise exception.NodeProtected(node=node.uuid)
event = 'rebuild'
# Note(gilliard) Clear these to force the driver to
@ -881,7 +905,8 @@ class ConductorManager(base_manager.BaseConductorManager):
@messaging.expected_exceptions(exception.NoFreeConductorWorker,
exception.NodeLocked,
exception.InstanceDeployFailure,
exception.InvalidStateRequested)
exception.InvalidStateRequested,
exception.NodeProtected)
def do_node_tear_down(self, context, node_id):
"""RPC method to tear down an existing node deployment.
@ -895,12 +920,15 @@ class ConductorManager(base_manager.BaseConductorManager):
async task
:raises: InvalidStateRequested when the requested state is not a valid
target from the current state.
:raises: NodeProtected if the node is protected.
"""
LOG.debug("RPC do_node_tear_down called for node %s.", node_id)
with task_manager.acquire(context, node_id, shared=False,
purpose='node tear down') as task:
if task.node.protected:
raise exception.NodeProtected(node=task.node.uuid)
try:
# NOTE(ghe): Valid power driver values are needed to perform
# a tear-down. Deploy info is useful to purge the cache but not
@ -2129,7 +2157,8 @@ class ConductorManager(base_manager.BaseConductorManager):
@METRICS.timer('ConductorManager.destroy_node')
@messaging.expected_exceptions(exception.NodeLocked,
exception.NodeAssociated,
exception.InvalidState)
exception.InvalidState,
exception.NodeProtected)
def destroy_node(self, context, node_id):
"""Delete a node.
@ -2140,7 +2169,7 @@ class ConductorManager(base_manager.BaseConductorManager):
associated with it.
:raises: InvalidState if the node is in the wrong provision
state to perform deletion.
:raises: NodeProtected if the node is protected.
"""
# NOTE(dtantsur): we allow deleting a node in maintenance mode even if
# we would disallow it otherwise. That's done for recovering hopelessly
@ -2152,6 +2181,9 @@ class ConductorManager(base_manager.BaseConductorManager):
raise exception.NodeAssociated(node=node.uuid,
instance=node.instance_uuid)
if task.node.protected:
raise exception.NodeProtected(node=node.uuid)
# NOTE(lucasagomes): For the *FAIL states we users should
# move it to a safe state prior to deletion. This is because we
# should try to avoid deleting a node in a dirty/whacky state,

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 Node.protected field
Revision ID: 93706939026c
Revises: d2b036ae9378
Create Date: 2018-10-18 14:55:12.489170
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '93706939026c'
down_revision = 'd2b036ae9378'
def upgrade():
op.add_column('nodes', sa.Column('protected', sa.Boolean(), nullable=False,
server_default=sa.false()))
op.add_column('nodes', sa.Column('protected_reason', sa.Text(),
nullable=True))

View File

@ -24,7 +24,7 @@ from oslo_db import options as db_options
from oslo_db.sqlalchemy import models
from oslo_db.sqlalchemy import types as db_types
import six.moves.urllib.parse as urlparse
from sqlalchemy import Boolean, Column, DateTime, Index
from sqlalchemy import Boolean, Column, DateTime, false, Index
from sqlalchemy import ForeignKey, Integer
from sqlalchemy import schema, String, Text
from sqlalchemy.ext.declarative import declarative_base
@ -176,6 +176,9 @@ class Node(Base):
inspection_started_at = Column(DateTime, nullable=True)
extra = Column(db_types.JsonEncodedDict)
automated_clean = Column(Boolean, nullable=True)
protected = Column(Boolean, nullable=False, default=False,
server_default=false())
protected_reason = Column(Text, nullable=True)
bios_interface = Column(String(255), nullable=True)
boot_interface = Column(String(255), nullable=True)

View File

@ -65,7 +65,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# Version 1.26: Add deploy_step field
# Version 1.27: Add conductor_group field
# Version 1.28: Add automated_clean field
VERSION = '1.28'
# Version 1.29: Add protected and protected_reason fields
VERSION = '1.29'
dbapi = db_api.get_instance()
@ -132,6 +133,9 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
'extra': object_fields.FlexibleDictField(nullable=True),
'automated_clean': objects.fields.BooleanField(nullable=True),
'protected': objects.fields.BooleanField(),
'protected_reason': object_fields.StringField(nullable=True),
'bios_interface': object_fields.StringField(nullable=True),
'boot_interface': object_fields.StringField(nullable=True),
'console_interface': object_fields.StringField(nullable=True),
@ -575,6 +579,8 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
this, it should be removed.
Version 1.28: automated_clean was added. For versions prior to this, it
should be set to None (or removed).
Version 1.29: protected 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
@ -586,11 +592,16 @@ class Node(base.IronicObject, object_base.VersionedObjectDictCompat):
# Convert the different fields depending on version
fields = [('rescue_interface', 22), ('traits', 23),
('bios_interface', 24), ('automated_clean', 28)]
('bios_interface', 24), ('automated_clean', 28),
('protected_reason', 29)]
for name, minor in fields:
self._adjust_field_to_version(name, None, target_version,
1, minor, remove_unavailable_fields)
# NOTE(dtantsur): the default is False for protected
self._adjust_field_to_version('protected', False, target_version,
1, 29, remove_unavailable_fields)
self._convert_fault_field(target_version, remove_unavailable_fields)
self._convert_deploy_step_field(target_version,
remove_unavailable_fields)
@ -639,6 +650,8 @@ class NodePayload(notification.NotificationPayloadBase):
'vendor_interface': ('node', 'vendor_interface'),
'power_state': ('node', 'power_state'),
'properties': ('node', 'properties'),
'protected': ('node', 'protected'),
'protected_reason': ('node', 'protected_reason'),
'provision_state': ('node', 'provision_state'),
'provision_updated_at': ('node', 'provision_updated_at'),
'resource_class': ('node', 'resource_class'),
@ -660,7 +673,8 @@ class NodePayload(notification.NotificationPayloadBase):
# Version 1.8: Add bios interface field exposed via API.
# Version 1.9: Add deploy_step field exposed via API.
# Version 1.10: Add conductor_group field exposed via API.
VERSION = '1.10'
# Version 1.11: Add protected and protected_reason fields exposed via API.
VERSION = '1.11'
fields = {
'clean_step': object_fields.FlexibleDictField(nullable=True),
'conductor_group': object_fields.StringField(nullable=True),
@ -691,6 +705,8 @@ class NodePayload(notification.NotificationPayloadBase):
'name': object_fields.StringField(nullable=True),
'power_state': object_fields.StringField(nullable=True),
'properties': object_fields.FlexibleDictField(nullable=True),
'protected': object_fields.BooleanField(nullable=True),
'protected_reason': object_fields.StringField(nullable=True),
'provision_state': object_fields.StringField(nullable=True),
'provision_updated_at': object_fields.DateTimeField(nullable=True),
'resource_class': object_fields.StringField(nullable=True),
@ -737,7 +753,8 @@ class NodeSetPowerStatePayload(NodePayload):
# Version 1.8: Parent NodePayload version 1.8
# Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10
VERSION = '1.10'
# Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11'
fields = {
# "to_power" indicates the future target_power_state of the node. A
@ -788,7 +805,8 @@ class NodeCorrectedPowerStatePayload(NodePayload):
# Version 1.8: Parent NodePayload version 1.8
# Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10
VERSION = '1.10'
# Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11'
fields = {
'from_power': object_fields.StringField(nullable=True)
@ -823,7 +841,8 @@ class NodeSetProvisionStatePayload(NodePayload):
# Version 1.8: Parent NodePayload version 1.8
# Version 1.9: Parent NodePayload version 1.9
# Version 1.10: Parent NodePayload version 1.10
VERSION = '1.10'
# Version 1.11: Parent NodePayload version 1.11
VERSION = '1.11'
SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info')})
@ -865,7 +884,8 @@ class NodeCRUDPayload(NodePayload):
# Version 1.6: Parent NodePayload version 1.8
# Version 1.7: Parent NodePayload version 1.9
# Version 1.8: Parent NodePayload version 1.10
VERSION = '1.8'
# Version 1.9: Parent NodePayload version 1.11
VERSION = '1.9'
SCHEMA = dict(NodePayload.SCHEMA,
**{'instance_info': ('node', 'instance_info'),

View File

@ -125,6 +125,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertNotIn('deploy_step', data['nodes'][0])
self.assertNotIn('conductor_group', data['nodes'][0])
self.assertNotIn('automated_clean', data['nodes'][0])
self.assertNotIn('protected', data['nodes'][0])
self.assertNotIn('protected_reason', data['nodes'][0])
def test_get_one(self):
node = obj_utils.create_test_node(self.context,
@ -164,6 +166,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('deploy_step', data)
self.assertIn('conductor_group', data)
self.assertIn('automated_clean', data)
self.assertIn('protected', data)
self.assertIn('protected_reason', data)
def test_get_one_with_json(self):
# Test backward compatibility with guess_content_type_from_ext
@ -286,6 +290,33 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.47'})
self.assertEqual(data['automated_clean'], False)
def test_node_protected_hidden_in_lower_version(self):
self._test_node_field_hidden_in_lower_version('protected',
'1.47', '1.48')
def test_node_protected_reason_hidden_in_lower_version(self):
self._test_node_field_hidden_in_lower_version('protected_reason',
'1.47', '1.48')
def test_node_protected(self):
for value in (True, False):
node = obj_utils.create_test_node(self.context, protected=value,
provision_state='active',
uuid=uuidutils.generate_uuid())
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.48'})
self.assertIs(data['protected'], value)
self.assertIsNone(data['protected_reason'])
def test_node_protected_with_reason(self):
node = obj_utils.create_test_node(self.context, protected=True,
provision_state='active',
protected_reason='reason!')
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.48'})
self.assertTrue(data['protected'])
self.assertEqual('reason!', data['protected_reason'])
def test_get_one_custom_fields(self):
node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
@ -450,6 +481,14 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.47'})
self.assertIn('automated_clean', response)
def test_get_protected_fields(self):
node = obj_utils.create_test_node(self.context,
protected=True)
response = self.get_json('/nodes/%s?fields=%s' %
(node.uuid, 'protected'),
headers={api_base.Version.string: '1.48'})
self.assertIn('protected', response)
def test_detail(self):
node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)
@ -481,6 +520,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('traits', data['nodes'][0])
self.assertIn('conductor_group', data['nodes'][0])
self.assertIn('automated_clean', data['nodes'][0])
self.assertIn('protected', data['nodes'][0])
self.assertIn('protected_reason', data['nodes'][0])
# never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0])
@ -511,6 +552,8 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('resource_class', data['nodes'][0])
self.assertIn('conductor_group', data['nodes'][0])
self.assertIn('automated_clean', data['nodes'][0])
self.assertIn('protected', data['nodes'][0])
self.assertIn('protected_reason', data['nodes'][0])
for field in api_utils.V31_FIELDS:
self.assertIn(field, data['nodes'][0])
# never expose the chassis_id
@ -2636,6 +2679,66 @@ class TestPatch(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_code)
def test_update_protected(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.48'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/protected',
'value': True,
'op': 'replace'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_protected_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.48'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/protected',
'value': True,
'op': 'replace'},
{'path': '/protected_reason',
'value': 'reason!',
'op': 'replace'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_protected_reason(self):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
provision_state='active',
protected=True)
self.mock_update_node.return_value = node
headers = {api_base.Version.string: '1.48'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/protected_reason',
'value': 'reason!',
'op': 'replace'}],
headers=headers)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.OK, response.status_code)
def test_update_protected_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.47'}
response = self.patch_json('/nodes/%s' % node.uuid,
[{'path': '/protected',
'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)
@ -3235,6 +3338,14 @@ class TestPost(test_api_base.BaseApiTest):
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.NOT_ACCEPTABLE, response.status_int)
def test_create_node_protected_not_allowed(self):
headers = {api_base.Version.string: '1.48'}
ndict = test_api_utils.post_get_test_node(protected=True)
response = self.post_json('/nodes', ndict, headers=headers,
expect_errors=True)
self.assertEqual('application/json', response.content_type)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
class TestDelete(test_api_base.BaseApiTest):

View File

@ -491,6 +491,65 @@ class UpdateNodeTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
self.assertIsNone(res['maintenance_reason'])
self.assertIsNone(res['fault'])
def test_update_node_protected_set(self):
for state in ('active', 'rescue'):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
provision_state=state)
node.protected = True
res = self.service.update_node(self.context, node)
self.assertTrue(res['protected'])
self.assertIsNone(res['protected_reason'])
def test_update_node_protected_unset(self):
# NOTE(dtantsur): we allow unsetting protected in any state to make
# sure a node cannot get stuck in it.
for state in ('active', 'rescue', 'rescue failed'):
node = obj_utils.create_test_node(self.context,
uuid=uuidutils.generate_uuid(),
provision_state=state,
protected=True,
protected_reason='reason')
# check that ManagerService.update_node actually updates the node
node.protected = False
res = self.service.update_node(self.context, node)
self.assertFalse(res['protected'])
self.assertIsNone(res['protected_reason'])
def test_update_node_protected_invalid_state(self):
node = obj_utils.create_test_node(self.context,
provision_state='available')
node.protected = 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['protected'])
self.assertIsNone(res['protected_reason'])
def test_update_node_protected_reason_without_protected(self):
node = obj_utils.create_test_node(self.context,
provision_state='active')
node.protected_reason = 'reason!'
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['protected'])
self.assertIsNone(res['protected_reason'])
def test_update_node_already_locked(self):
node = obj_utils.create_test_node(self.context, driver='fake-hardware',
extra={'test': 'one'})
@ -1546,6 +1605,23 @@ class ServiceDoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin,
mock_iwdi.assert_called_once_with(self.context, node.instance_info)
self.assertNotIn('is_whole_disk_image', node.driver_internal_info)
def test_do_node_deploy_rebuild_protected(self, mock_iwdi):
mock_iwdi.return_value = False
self._start_service()
node = obj_utils.create_test_node(self.context, driver='fake-hardware',
provision_state=states.ACTIVE,
protected=True)
exc = self.assertRaises(messaging.rpc.ExpectedException,
self.service.do_node_deploy,
self.context, node['uuid'], rebuild=True)
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.NodeProtected, exc.exc_info[0])
# Last_error should be None.
self.assertIsNone(node.last_error)
# Verify reservation has been cleared.
self.assertIsNone(node.reservation)
self.assertFalse(mock_iwdi.called)
def test_do_node_deploy_worker_pool_full(self, mock_iwdi):
mock_iwdi.return_value = False
prv_state = states.AVAILABLE
@ -2567,6 +2643,18 @@ class DoNodeTearDownTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.InvalidStateRequested, exc.exc_info[0])
def test_do_node_tear_down_protected(self):
self._start_service()
# test node.provision_state is incorrect for tear_down
node = obj_utils.create_test_node(self.context, driver='fake-hardware',
provision_state=states.ACTIVE,
protected=True)
exc = self.assertRaises(messaging.rpc.ExpectedException,
self.service.do_node_tear_down,
self.context, node['uuid'])
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.NodeProtected, exc.exc_info[0])
@mock.patch('ironic.drivers.modules.fake.FakePower.validate')
def test_do_node_tear_down_validate_fail(self, mock_validate):
# InvalidParameterValue should be re-raised as InstanceDeployFailure
@ -4866,6 +4954,24 @@ class DestroyNodeTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
node.refresh()
self.assertIsNone(node.reservation)
def test_destroy_node_protected(self):
self._start_service()
node = obj_utils.create_test_node(self.context,
provision_state=states.ACTIVE,
protected=True,
# Even in maintenance the protected
# nodes are not deleted
maintenance=True)
exc = self.assertRaises(messaging.rpc.ExpectedException,
self.service.destroy_node,
self.context, node.uuid)
# Compare true exception hidden by @messaging.expected_exceptions
self.assertEqual(exception.NodeProtected, exc.exc_info[0])
# Verify reservation was released.
node.refresh()
self.assertIsNone(node.reservation)
def test_destroy_node_allowed_in_maintenance(self):
self._start_service()
node = obj_utils.create_test_node(

View File

@ -765,6 +765,27 @@ class MigrationCheckersMixin(object):
col_names = [column.name for column in nodes.c]
self.assertIn('automated_clean', col_names)
def _pre_upgrade_93706939026c(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_93706939026c(self, engine, data):
nodes = db_utils.get_table(engine, 'nodes')
col_names = [column.name for column in nodes.c]
self.assertIn('protected', col_names)
self.assertIn('protected_reason', col_names)
node = nodes.select(
nodes.c.uuid == data['node_uuid']).execute().first()
self.assertFalse(node['protected'])
self.assertIsNone(node['protected_reason'])
def test_upgrade_and_version(self):
with patch_with_engine(self.engine):
self.migration_api.upgrade('head')

View File

@ -215,6 +215,8 @@ def get_test_node(**kw):
'resource_class': kw.get('resource_class'),
'traits': kw.get('traits', []),
'automated_clean': kw.get('automated_clean', None),
'protected': kw.get('protected', False),
'protected_reason': kw.get('protected_reason', None),
}
for iface in drivers_base.ALL_INTERFACES:

View File

@ -769,6 +769,66 @@ class TestConvertToVersion(db_base.DbTestCase):
self.assertIsNone(node.automated_clean)
self.assertEqual({}, node.obj_get_changes())
def test_protected_supported_missing(self):
# protected_interface not set, should be set to default.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
delattr(node, 'protected')
delattr(node, 'protected_reason')
node.obj_reset_changes()
node._convert_to_version("1.29")
self.assertFalse(node.protected)
self.assertIsNone(node.protected_reason)
self.assertEqual({'protected': False, 'protected_reason': None},
node.obj_get_changes())
def test_protected_supported_set(self):
# protected set, no change required.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.protected = True
node.protected_reason = 'foo'
node.obj_reset_changes()
node._convert_to_version("1.29")
self.assertTrue(node.protected)
self.assertEqual('foo', node.protected_reason)
self.assertEqual({}, node.obj_get_changes())
def test_protected_unsupported_missing(self):
# protected not set, no change required.
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
delattr(node, 'protected')
delattr(node, 'protected_reason')
node.obj_reset_changes()
node._convert_to_version("1.28")
self.assertNotIn('protected', node)
self.assertNotIn('protected_reason', node)
self.assertEqual({}, node.obj_get_changes())
def test_protected_unsupported_set_remove(self):
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.protected = True
node.protected_reason = 'foo'
node.obj_reset_changes()
node._convert_to_version("1.28")
self.assertNotIn('protected', node)
self.assertNotIn('protected_reason', node)
self.assertEqual({}, node.obj_get_changes())
def test_protected_unsupported_set_no_remove_non_default(self):
node = obj_utils.get_test_node(self.ctxt, **self.fake_node)
node.protected = True
node.protected_reason = 'foo'
node.obj_reset_changes()
node._convert_to_version("1.28", False)
self.assertIsNone(node.automated_clean)
self.assertEqual({'protected': False, 'protected_reason': None},
node.obj_get_changes())
class TestNodePayloads(db_base.DbTestCase):

View File

@ -677,7 +677,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.28-d4aba1f583774326903f7366fbaae752',
'Node': '1.29-7af860bb4017751104558139c52a1327',
'MyObj': '1.5-9459d30d6954bffc7a9afd347a807ca6',
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
'Port': '1.8-898a47921f4a1f53fcdddd4eeb179e0b',
@ -685,21 +685,21 @@ expected_object_fingerprints = {
'Conductor': '1.3-d3f53e853b4d58cae5bfbd9a8341af4a',
'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370',
'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d',
'NodePayload': '1.10-618d4ebd121671f463836b7c4ec45114',
'NodePayload': '1.11-f323602c2e9c3edbf2a5567eca087ff5',
'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeSetPowerStatePayload': '1.10-74b186077c80e13060de5e2dac9baed5',
'NodeSetPowerStatePayload': '1.11-b61e66ef9d100a2cc564d16b12810855',
'NodeCorrectedPowerStateNotification':
'1.0-59acc533c11d306f149846f922739c15',
'NodeCorrectedPowerStatePayload': '1.10-f85fc83dceda2d7ed356368cda0f008f',
'NodeCorrectedPowerStatePayload': '1.11-e6e32a38ca655509802ac3c6d8bc17f6',
'NodeSetProvisionStateNotification':
'1.0-59acc533c11d306f149846f922739c15',
'NodeSetProvisionStatePayload': '1.10-80e9581b0663dd47362f0b7ab19e4674',
'NodeSetProvisionStatePayload': '1.11-d13cb3472eea163de5b0723a08e95d2c',
'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97',
'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e',
'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202',
'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'NodeCRUDPayload': '1.8-8086c2706c8f89db8294ab7511b9337b',
'NodeCRUDPayload': '1.9-c5e57432274371f7fe32f269519033cf',
'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
'PortCRUDPayload': '1.2-233d259df442eb15cc584fae1fe81504',
'NodeMaintenanceNotification': '1.0-59acc533c11d306f149846f922739c15',

View File

@ -0,0 +1,7 @@
---
features:
- |
It is now possible to protect a provisioned node from being undeployed,
rebuilt or deleted by setting the new ``protected`` field to ``True``.
The new ``protected_reason`` field can be used to document the reason
the node was made protected.