Add API methods for [un]rescue
Adds API methods to support rescue and unrescue. After rescuing a node, it will be left running a rescue ramdisk, configured with the rescue_password, and listening with ssh on the specified network interfaces. Unrescuing a node will return the node to Active. Change-Id: I3953ff0b1ca000f8ae83fb7b3c663f848a149345 Partial-bug: #1526449 Co-Authored-By: Jay Faulkner <jay@jvf.cc> Co-Authored-By: Josh Gachnang <josh@pcsforeducation.com> Co-Authored-By: Jesse J. Cook <jesse.j.cook@member.fsf.org> Co-Authored-By: Mario Villaplana <mario.villaplana@gmail.com> Co-Authored-By: Aparna <aparnavtce@gmail.com> Co-Authored-By: Shivanand Tendulker <stendulker@gmail.com>
This commit is contained in:
parent
e95f3de5c9
commit
49fabe6d7b
@ -2,6 +2,20 @@
|
||||
REST API Version History
|
||||
========================
|
||||
|
||||
1.38 (Queens, 10.1.0)
|
||||
---------------------
|
||||
|
||||
Added provision_state verbs ``rescue`` and ``unrescue`` along with
|
||||
the following states: ``rescue``, ``rescue failed``, ``rescue wait``,
|
||||
``rescuing``, ``unrescue failed``, and ``unrescuing``. After rescuing
|
||||
a node, it will be left in the ``rescue`` state running a rescue
|
||||
ramdisk, configured with the ``rescue_password``, and listening with
|
||||
ssh on the specified network interfaces. Unrescuing a node will return
|
||||
it to ``active``.
|
||||
|
||||
Added ``rescue_interface`` to the node object, to
|
||||
allow setting the rescue interface for a dynamic driver.
|
||||
|
||||
1.37 (Queens, 10.1.0)
|
||||
---------------------
|
||||
|
||||
@ -36,7 +50,7 @@ Added ``agent_version`` parameter to deploy heartbeat request for version
|
||||
negotiation with Ironic Python Agent features.
|
||||
|
||||
1.35 (Queens, 9.2.0)
|
||||
---------------------
|
||||
--------------------
|
||||
|
||||
Added ability to provide ``configdrive`` when node is updated
|
||||
to ``rebuild`` provision state.
|
||||
|
@ -75,6 +75,10 @@ def hide_fields_in_newer_versions(obj):
|
||||
obj.default_storage_interface = wsme.Unset
|
||||
obj.enabled_storage_interfaces = wsme.Unset
|
||||
|
||||
if not api_utils.allow_rescue_interface():
|
||||
obj.default_rescue_interface = wsme.Unset
|
||||
obj.enabled_rescue_interfaces = wsme.Unset
|
||||
|
||||
|
||||
class Driver(base.APIBase):
|
||||
"""API representation of a driver."""
|
||||
@ -103,6 +107,7 @@ class Driver(base.APIBase):
|
||||
default_network_interface = wtypes.text
|
||||
default_power_interface = wtypes.text
|
||||
default_raid_interface = wtypes.text
|
||||
default_rescue_interface = wtypes.text
|
||||
default_storage_interface = wtypes.text
|
||||
default_vendor_interface = wtypes.text
|
||||
|
||||
@ -115,6 +120,7 @@ class Driver(base.APIBase):
|
||||
enabled_network_interfaces = [wtypes.text]
|
||||
enabled_power_interfaces = [wtypes.text]
|
||||
enabled_raid_interfaces = [wtypes.text]
|
||||
enabled_rescue_interfaces = [wtypes.text]
|
||||
enabled_storage_interfaces = [wtypes.text]
|
||||
enabled_vendor_interfaces = [wtypes.text]
|
||||
|
||||
|
@ -159,6 +159,9 @@ def hide_fields_in_newer_versions(obj):
|
||||
if not api_utils.allow_traits():
|
||||
obj.traits = wsme.Unset
|
||||
|
||||
if not api_utils.allow_rescue_interface():
|
||||
obj.rescue_interface = wsme.Unset
|
||||
|
||||
|
||||
def update_state_in_older_versions(obj):
|
||||
"""Change provision state names for API backwards compatibility.
|
||||
@ -546,10 +549,10 @@ class NodeStatesController(rest.RestController):
|
||||
|
||||
@METRICS.timer('NodeStatesController.provision')
|
||||
@expose.expose(None, types.uuid_or_name, wtypes.text,
|
||||
wtypes.text, types.jsontype,
|
||||
wtypes.text, types.jsontype, wtypes.text,
|
||||
status_code=http_client.ACCEPTED)
|
||||
def provision(self, node_ident, target, configdrive=None,
|
||||
clean_steps=None):
|
||||
clean_steps=None, rescue_password=None):
|
||||
"""Asynchronous trigger the provisioning of the node.
|
||||
|
||||
This will set the target provision state of the node, and a
|
||||
@ -582,6 +585,9 @@ class NodeStatesController(rest.RestController):
|
||||
'args': {'force': True} }
|
||||
|
||||
This is required (and only valid) when target is "clean".
|
||||
:param rescue_password: A string representing the password to be set
|
||||
inside the rescue environment. This is required (and only valid),
|
||||
when target is "rescue".
|
||||
:raises: NodeLocked (HTTP 409) if the node is currently locked.
|
||||
:raises: ClientSideError (HTTP 409) if the node is already being
|
||||
provisioned.
|
||||
@ -634,6 +640,13 @@ class NodeStatesController(rest.RestController):
|
||||
raise wsme.exc.ClientSideError(
|
||||
msg, status_code=http_client.BAD_REQUEST)
|
||||
|
||||
if (rescue_password is not None and
|
||||
target != ir_states.VERBS['rescue']):
|
||||
msg = (_('"rescue_password" is only valid when setting target '
|
||||
'provision state to %s') % ir_states.VERBS['rescue'])
|
||||
raise wsme.exc.ClientSideError(
|
||||
msg, status_code=http_client.BAD_REQUEST)
|
||||
|
||||
# Note that there is a race condition. The node state(s) could change
|
||||
# by the time the RPC call is made and the TaskManager manager gets a
|
||||
# lock.
|
||||
@ -644,6 +657,18 @@ class NodeStatesController(rest.RestController):
|
||||
rebuild=rebuild,
|
||||
configdrive=configdrive,
|
||||
topic=topic)
|
||||
elif (target == ir_states.VERBS['unrescue']):
|
||||
pecan.request.rpcapi.do_node_unrescue(
|
||||
pecan.request.context, rpc_node.uuid, topic)
|
||||
elif (target == ir_states.VERBS['rescue']):
|
||||
if not (rescue_password and rescue_password.strip()):
|
||||
msg = (_('A non-empty "rescue_password" is required when '
|
||||
'setting target provision state to %s') %
|
||||
ir_states.VERBS['rescue'])
|
||||
raise wsme.exc.ClientSideError(
|
||||
msg, status_code=http_client.BAD_REQUEST)
|
||||
pecan.request.rpcapi.do_node_rescue(
|
||||
pecan.request.context, rpc_node.uuid, rescue_password, topic)
|
||||
elif target == ir_states.DELETED:
|
||||
pecan.request.rpcapi.do_node_tear_down(
|
||||
pecan.request.context, rpc_node.uuid, topic)
|
||||
@ -947,6 +972,9 @@ class Node(base.APIBase):
|
||||
raid_interface = wsme.wsattr(wtypes.text)
|
||||
"""The raid interface to be used for this node"""
|
||||
|
||||
rescue_interface = wsme.wsattr(wtypes.text)
|
||||
"""The rescue interface to be used for this node"""
|
||||
|
||||
storage_interface = wsme.wsattr(wtypes.text)
|
||||
"""The storage interface to be used for this node"""
|
||||
|
||||
@ -1110,7 +1138,7 @@ class Node(base.APIBase):
|
||||
deploy_interface=None, inspect_interface=None,
|
||||
management_interface=None, power_interface=None,
|
||||
raid_interface=None, vendor_interface=None,
|
||||
storage_interface=None, traits=[])
|
||||
storage_interface=None, traits=[], rescue_interface=None)
|
||||
# NOTE(matty_dubs): The chassis_uuid getter() is based on the
|
||||
# _chassis_uuid variable:
|
||||
sample._chassis_uuid = 'edcad704-b2da-41d5-96d9-afd580ecfa12'
|
||||
@ -1748,6 +1776,10 @@ class NodesController(rest.RestController):
|
||||
"be set via the node traits API.")
|
||||
raise exception.Invalid(msg)
|
||||
|
||||
if (not api_utils.allow_rescue_interface() and
|
||||
node.rescue_interface is not wtypes.Unset):
|
||||
raise exception.NotAcceptable()
|
||||
|
||||
# NOTE(deva): get_topic_for checks if node.driver is in the hash ring
|
||||
# and raises NoValidHost if it is not.
|
||||
# We need to ensure that node has a UUID before it can
|
||||
@ -1825,6 +1857,10 @@ class NodesController(rest.RestController):
|
||||
"should be updated via the node traits API.")
|
||||
raise exception.Invalid(msg)
|
||||
|
||||
r_interface = api_utils.get_patch_values(patch, '/rescue_interface')
|
||||
if r_interface and not api_utils.allow_rescue_interface():
|
||||
raise exception.NotAcceptable()
|
||||
|
||||
rpc_node = api_utils.get_rpc_node(node_ident)
|
||||
|
||||
remove_inst_uuid_patch = [{'op': 'remove', 'path': '/instance_uuid'}]
|
||||
|
@ -38,7 +38,8 @@ _LOOKUP_RETURN_FIELDS = ('uuid', 'properties', 'instance_info',
|
||||
'driver_internal_info')
|
||||
_LOOKUP_ALLOWED_STATES = {states.DEPLOYING, states.DEPLOYWAIT,
|
||||
states.CLEANING, states.CLEANWAIT,
|
||||
states.INSPECTING}
|
||||
states.INSPECTING,
|
||||
states.RESCUING, states.RESCUEWAIT}
|
||||
|
||||
|
||||
def config():
|
||||
|
@ -54,6 +54,8 @@ MIN_VERB_VERSIONS = {
|
||||
states.VERBS['abort']: versions.MINOR_13_ABORT_VERB,
|
||||
states.VERBS['clean']: versions.MINOR_15_MANUAL_CLEAN,
|
||||
states.VERBS['adopt']: versions.MINOR_17_ADOPT_VERB,
|
||||
states.VERBS['rescue']: versions.MINOR_38_RESCUE_INTERFACE,
|
||||
states.VERBS['unrescue']: versions.MINOR_38_RESCUE_INTERFACE,
|
||||
}
|
||||
|
||||
V31_FIELDS = [
|
||||
@ -325,6 +327,8 @@ def check_allowed_fields(fields):
|
||||
raise exception.NotAcceptable()
|
||||
if 'traits' in fields and not allow_traits():
|
||||
raise exception.NotAcceptable()
|
||||
if 'rescue_interface' in fields and not allow_rescue_interface():
|
||||
raise exception.NotAcceptable()
|
||||
|
||||
|
||||
def check_allowed_portgroup_fields(fields):
|
||||
@ -635,6 +639,14 @@ def allow_agent_version_in_heartbeat():
|
||||
versions.MINOR_36_AGENT_VERSION_HEARTBEAT)
|
||||
|
||||
|
||||
def allow_rescue_interface():
|
||||
"""Check if we should support rescue and unrescue operations and interface.
|
||||
|
||||
Version 1.38 of the API added support for rescue and unrescue.
|
||||
"""
|
||||
return pecan.request.version.minor >= versions.MINOR_38_RESCUE_INTERFACE
|
||||
|
||||
|
||||
def get_controller_reserved_names(cls):
|
||||
"""Get reserved names for a given controller.
|
||||
|
||||
|
@ -74,6 +74,8 @@ BASE_VERSION = 1
|
||||
# v1.35: Add ability to provide configdrive when rebuilding node.
|
||||
# v1.36: Add Ironic Python Agent version support.
|
||||
# v1.37: Add node traits.
|
||||
# v1.38: Add rescue and unrescue provision states
|
||||
# Add rescue_interface to the node object
|
||||
|
||||
MINOR_0_JUNO = 0
|
||||
MINOR_1_INITIAL_VERSION = 1
|
||||
@ -113,6 +115,7 @@ MINOR_34_PORT_PHYSICAL_NETWORK = 34
|
||||
MINOR_35_REBUILD_CONFIG_DRIVE = 35
|
||||
MINOR_36_AGENT_VERSION_HEARTBEAT = 36
|
||||
MINOR_37_NODE_TRAITS = 37
|
||||
MINOR_38_RESCUE_INTERFACE = 38
|
||||
|
||||
# When adding another version, update:
|
||||
# - MINOR_MAX_VERSION
|
||||
@ -120,7 +123,7 @@ MINOR_37_NODE_TRAITS = 37
|
||||
# explanation of what changed in the new version
|
||||
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
|
||||
|
||||
MINOR_MAX_VERSION = MINOR_37_NODE_TRAITS
|
||||
MINOR_MAX_VERSION = MINOR_38_RESCUE_INTERFACE
|
||||
|
||||
# String representations of the minor and maximum versions
|
||||
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
||||
|
@ -121,7 +121,7 @@ RELEASE_MAPPING = {
|
||||
}
|
||||
},
|
||||
'master': {
|
||||
'api': '1.37',
|
||||
'api': '1.38',
|
||||
'rpc': '1.44',
|
||||
'objects': {
|
||||
'Node': ['1.23'],
|
||||
|
@ -1820,11 +1820,6 @@ class ConductorManager(base_manager.BaseConductorManager):
|
||||
task.node.instance_info)
|
||||
task.node.driver_internal_info['is_whole_disk_image'] = iwdi
|
||||
for iface_name in task.driver.non_vendor_interfaces:
|
||||
# TODO(stendulker): Remove this check in 'rescue' API patch
|
||||
# Do not have to return the validation result for 'rescue'
|
||||
# interface.
|
||||
if iface_name == 'rescue':
|
||||
continue
|
||||
iface = getattr(task.driver, iface_name, None)
|
||||
result = reason = None
|
||||
if iface:
|
||||
|
@ -48,7 +48,7 @@ class TestListDrivers(base.BaseApiTest):
|
||||
self.dbapi.register_conductor_hardware_interfaces(
|
||||
c.id, self.d3, 'deploy', ['iscsi', 'direct'], 'direct')
|
||||
|
||||
def _test_drivers(self, use_dynamic, detail=False, storage_if=False):
|
||||
def _test_drivers(self, use_dynamic, detail=False, latest_if=False):
|
||||
self.register_fake_conductors()
|
||||
headers = {}
|
||||
expected = [
|
||||
@ -58,8 +58,8 @@ class TestListDrivers(base.BaseApiTest):
|
||||
]
|
||||
expected = sorted(expected, key=lambda d: d['name'])
|
||||
if use_dynamic:
|
||||
if storage_if:
|
||||
headers[api_base.Version.string] = '1.33'
|
||||
if latest_if:
|
||||
headers[api_base.Version.string] = '1.38'
|
||||
else:
|
||||
headers[api_base.Version.string] = '1.30'
|
||||
|
||||
@ -86,10 +86,14 @@ class TestListDrivers(base.BaseApiTest):
|
||||
# as this case can't actually happen.
|
||||
if detail:
|
||||
self.assertIn('default_deploy_interface', d)
|
||||
if storage_if:
|
||||
if latest_if:
|
||||
self.assertIn('default_rescue_interface', d)
|
||||
self.assertIn('enabled_rescue_interfaces', d)
|
||||
self.assertIn('default_storage_interface', d)
|
||||
self.assertIn('enabled_storage_interfaces', d)
|
||||
else:
|
||||
self.assertNotIn('default_rescue_interface', d)
|
||||
self.assertNotIn('enabled_rescue_interfaces', d)
|
||||
self.assertNotIn('default_storage_interface', d)
|
||||
self.assertNotIn('enabled_storage_interfaces', d)
|
||||
else:
|
||||
@ -103,7 +107,7 @@ class TestListDrivers(base.BaseApiTest):
|
||||
def test_drivers_with_dynamic(self):
|
||||
self._test_drivers(True)
|
||||
|
||||
def _test_drivers_with_dynamic_detailed(self, storage_if=False):
|
||||
def _test_drivers_with_dynamic_detailed(self, latest_if=False):
|
||||
with mock.patch.object(self.dbapi, 'list_hardware_type_interfaces',
|
||||
autospec=True) as mock_hw:
|
||||
mock_hw.return_value = [
|
||||
@ -121,13 +125,13 @@ class TestListDrivers(base.BaseApiTest):
|
||||
},
|
||||
]
|
||||
|
||||
self._test_drivers(True, detail=True, storage_if=storage_if)
|
||||
self._test_drivers(True, detail=True, latest_if=latest_if)
|
||||
|
||||
def test_drivers_with_dynamic_detailed(self):
|
||||
self._test_drivers_with_dynamic_detailed()
|
||||
|
||||
def test_drivers_with_dynamic_detailed_storage_interface(self):
|
||||
self._test_drivers_with_dynamic_detailed(storage_if=True)
|
||||
self._test_drivers_with_dynamic_detailed(latest_if=True)
|
||||
|
||||
def _test_drivers_type_filter(self, requested_type):
|
||||
self.register_fake_conductors()
|
||||
@ -179,7 +183,7 @@ class TestListDrivers(base.BaseApiTest):
|
||||
|
||||
@mock.patch.object(rpcapi.ConductorAPI, 'get_driver_properties')
|
||||
def _test_drivers_get_one_ok(self, use_dynamic, mock_driver_properties,
|
||||
storage_if=False):
|
||||
latest_if=False):
|
||||
# get_driver_properties mock is required by validate_link()
|
||||
self.register_fake_conductors()
|
||||
|
||||
@ -193,8 +197,8 @@ class TestListDrivers(base.BaseApiTest):
|
||||
hosts = [self.h1]
|
||||
|
||||
headers = {}
|
||||
if storage_if:
|
||||
headers[api_base.Version.string] = '1.33'
|
||||
if latest_if:
|
||||
headers[api_base.Version.string] = '1.38'
|
||||
else:
|
||||
headers[api_base.Version.string] = '1.30'
|
||||
|
||||
@ -208,12 +212,7 @@ class TestListDrivers(base.BaseApiTest):
|
||||
|
||||
if use_dynamic:
|
||||
for iface in driver_base.ALL_INTERFACES:
|
||||
# TODO(stendulker): Remove this check in 'rescue' API
|
||||
# patch.
|
||||
if iface == 'rescue':
|
||||
self.assertNotIn('default_rescue_interface', data)
|
||||
self.assertNotIn('enabled_rescue_interfaces', data)
|
||||
elif storage_if or iface != 'storage':
|
||||
if latest_if or iface not in ['rescue', 'storage']:
|
||||
self.assertIn('default_%s_interface' % iface, data)
|
||||
self.assertIn('enabled_%s_interfaces' % iface, data)
|
||||
self.assertIsNotNone(data['default_deploy_interface'])
|
||||
@ -230,7 +229,7 @@ class TestListDrivers(base.BaseApiTest):
|
||||
def test_drivers_get_one_ok_classic(self):
|
||||
self._test_drivers_get_one_ok(False)
|
||||
|
||||
def _test_drivers_get_one_ok_dynamic(self, storage_if=False):
|
||||
def _test_drivers_get_one_ok_dynamic(self, latest_if=False):
|
||||
with mock.patch.object(self.dbapi, 'list_hardware_type_interfaces',
|
||||
autospec=True) as mock_hw:
|
||||
mock_hw.return_value = [
|
||||
@ -248,14 +247,14 @@ class TestListDrivers(base.BaseApiTest):
|
||||
},
|
||||
]
|
||||
|
||||
self._test_drivers_get_one_ok(True, storage_if=storage_if)
|
||||
self._test_drivers_get_one_ok(True, latest_if=latest_if)
|
||||
mock_hw.assert_called_once_with([self.d3])
|
||||
|
||||
def test_drivers_get_one_ok_dynamic(self):
|
||||
def test_drivers_get_one_ok_dynamic_base_interfaces(self):
|
||||
self._test_drivers_get_one_ok_dynamic()
|
||||
|
||||
def test_drivers_get_one_ok_dynamic_storage_interface(self):
|
||||
self._test_drivers_get_one_ok_dynamic(storage_if=True)
|
||||
def test_drivers_get_one_ok_dynamic_latest_interfaces(self):
|
||||
self._test_drivers_get_one_ok_dynamic(latest_if=True)
|
||||
|
||||
def test_driver_properties_hidden_in_lower_version(self):
|
||||
self.register_fake_conductors()
|
||||
|
@ -2250,7 +2250,7 @@ class TestPost(test_api_base.BaseApiTest):
|
||||
self.assertEqual('neutron', result['network_interface'])
|
||||
|
||||
def test_create_node_specify_interfaces(self):
|
||||
headers = {api_base.Version.string: '1.33'}
|
||||
headers = {api_base.Version.string: '1.38'}
|
||||
all_interface_fields = api_utils.V31_FIELDS + ['network_interface',
|
||||
'rescue_interface',
|
||||
'storage_interface']
|
||||
@ -2268,11 +2268,6 @@ class TestPost(test_api_base.BaseApiTest):
|
||||
expected = 'flat'
|
||||
elif field == 'storage_interface':
|
||||
expected = 'noop'
|
||||
elif field == 'rescue_interface':
|
||||
# TODO(stendulker): Enable testing of rescue interface
|
||||
# in its API patch.
|
||||
continue
|
||||
|
||||
node = {
|
||||
'uuid': uuidutils.generate_uuid(),
|
||||
field: expected,
|
||||
@ -2955,6 +2950,12 @@ class TestPut(test_api_base.BaseApiTest):
|
||||
p = mock.patch.object(rpcapi.ConductorAPI, 'inspect_hardware')
|
||||
self.mock_dnih = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(rpcapi.ConductorAPI, 'do_node_rescue')
|
||||
self.mock_dnr = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(rpcapi.ConductorAPI, 'do_node_unrescue')
|
||||
self.mock_dnur = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _test_power_state_success(self, target_state, timeout, api_version):
|
||||
if timeout is None:
|
||||
@ -3326,6 +3327,271 @@ class TestPut(test_api_base.BaseApiTest):
|
||||
states.VERBS['provide'],
|
||||
'test-topic')
|
||||
|
||||
def test_rescue_raises_error_before_1_38(self):
|
||||
"""Test that a lower API client cannot use the rescue verb"""
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.37"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.NOT_ACCEPTABLE, ret.status_code)
|
||||
|
||||
def test_unrescue_raises_error_before_1_38(self):
|
||||
"""Test that a lower API client cannot use the unrescue verb"""
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.37"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.NOT_ACCEPTABLE, ret.status_code)
|
||||
|
||||
def test_provision_unexpected_rescue_password(self):
|
||||
self.node.provision_state = states.AVAILABLE
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.ACTIVE,
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_no_password(self):
|
||||
self.node.provision_state = states.ACTIVE
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['rescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_empty_password(self):
|
||||
self.node.provision_state = states.ACTIVE
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': ' '},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_in_active(self):
|
||||
self.node.provision_state = states.ACTIVE
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnr.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'password', 'test-topic')
|
||||
|
||||
def test_provision_rescue_in_deleting(self):
|
||||
node = self.node
|
||||
node.provision_state = states.DELETING
|
||||
node.target_provision_state = states.AVAILABLE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_in_rescue(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnr.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'password', 'test-topic')
|
||||
|
||||
def test_provision_rescue_in_rescuefail(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUEFAIL
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnr.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'password', 'test-topic')
|
||||
|
||||
def test_provision_rescue_in_rescuewait(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUEWAIT
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_in_rescuing(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUING
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_rescue_in_unrescuefail(self):
|
||||
node = self.node
|
||||
node.provision_state = states.UNRESCUEFAIL
|
||||
node.target_provision_state = states.ACTIVE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue'],
|
||||
'rescue_password': 'password'},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnr.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'password', 'test-topic')
|
||||
|
||||
def test_provision_rescue_in_unrescuing(self):
|
||||
node = self.node
|
||||
node.provision_state = states.UNRESCUING
|
||||
node.target_provision_state = states.ACTIVE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['rescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnr.called)
|
||||
|
||||
def test_provision_unrescue_in_active(self):
|
||||
node = self.node
|
||||
node.provision_state = states.ACTIVE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnur.called)
|
||||
|
||||
def test_provision_unrescue_in_deleting(self):
|
||||
node = self.node
|
||||
node.provision_state = states.DELETING
|
||||
node.target_provision_state = states.AVAILABLE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnur.called)
|
||||
|
||||
def test_provision_unrescue_in_rescue(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnur.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'test-topic')
|
||||
|
||||
def test_provision_unrescue_in_rescuefail(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUEFAIL
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnur.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'test-topic')
|
||||
|
||||
def test_provision_unrescue_in_rescuewait(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUEWAIT
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnur.called)
|
||||
|
||||
def test_provision_unrescue_in_rescuing(self):
|
||||
node = self.node
|
||||
node.provision_state = states.RESCUING
|
||||
node.target_provision_state = states.RESCUE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnur.called)
|
||||
|
||||
def test_provision_unrescue_in_unrescuefail(self):
|
||||
node = self.node
|
||||
node.provision_state = states.UNRESCUEFAIL
|
||||
node.target_provision_state = states.ACTIVE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
self.assertEqual(b'', ret.body)
|
||||
self.mock_dnur.assert_called_once_with(
|
||||
mock.ANY, self.node.uuid, 'test-topic')
|
||||
|
||||
def test_provision_unrescue_in_unrescuing(self):
|
||||
node = self.node
|
||||
node.provision_state = states.UNRESCUING
|
||||
node.target_provision_state = states.ACTIVE
|
||||
node.reservation = 'fake-host'
|
||||
node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % node.uuid,
|
||||
{'target': states.VERBS['unrescue']},
|
||||
headers={api_base.Version.string: "1.38"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.CONFLICT, ret.status_code)
|
||||
self.assertFalse(self.mock_dnur.called)
|
||||
|
||||
def test_inspect_already_in_progress(self):
|
||||
node = self.node
|
||||
node.provision_state = states.INSPECTING
|
||||
|
@ -201,6 +201,14 @@ class TestApiUtils(base.TestCase):
|
||||
utils.check_allowed_fields,
|
||||
['resource_class'])
|
||||
|
||||
@mock.patch.object(pecan, 'request', spec_set=['version'])
|
||||
def test_check_allowed_fields_rescue_interface_fail(self, mock_request):
|
||||
mock_request.version.minor = 31
|
||||
self.assertRaises(
|
||||
exception.NotAcceptable,
|
||||
utils.check_allowed_fields,
|
||||
['rescue_interface'])
|
||||
|
||||
@mock.patch.object(pecan, 'request', spec_set=['version'])
|
||||
def test_check_allowed_portgroup_fields_mode_properties(self,
|
||||
mock_request):
|
||||
@ -499,6 +507,13 @@ class TestApiUtils(base.TestCase):
|
||||
mock_request.version.minor = 34
|
||||
utils.check_allow_configdrive(states.ACTIVE)
|
||||
|
||||
@mock.patch.object(pecan, 'request', spec_set=['version'])
|
||||
def test_allow_rescue_interface(self, mock_request):
|
||||
mock_request.version.minor = 38
|
||||
self.assertTrue(utils.allow_rescue_interface())
|
||||
mock_request.version.minor = 37
|
||||
self.assertFalse(utils.allow_rescue_interface())
|
||||
|
||||
|
||||
class TestNodeIdent(base.TestCase):
|
||||
|
||||
|
@ -3223,12 +3223,13 @@ class MiscTestCase(mgr_utils.ServiceSetUpMixin, mgr_utils.CommonMixIn,
|
||||
'otherdriver'))
|
||||
|
||||
@mock.patch.object(images, 'is_whole_disk_image')
|
||||
def test_validate_driver_interfaces(self, mock_iwdi):
|
||||
def test_validate_dynamic_driver_interfaces(self, mock_iwdi):
|
||||
mock_iwdi.return_value = False
|
||||
target_raid_config = {'logical_disks': [{'size_gb': 1,
|
||||
'raid_level': '1'}]}
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake', target_raid_config=target_raid_config,
|
||||
self.context, driver='fake-hardware',
|
||||
target_raid_config=target_raid_config,
|
||||
network_interface='noop')
|
||||
ret = self.service.validate_driver_interfaces(self.context,
|
||||
node.uuid)
|
||||
@ -3240,8 +3241,32 @@ class MiscTestCase(mgr_utils.ServiceSetUpMixin, mgr_utils.CommonMixIn,
|
||||
'raid': {'result': True},
|
||||
'deploy': {'result': True},
|
||||
'network': {'result': True},
|
||||
'storage': {'result': True}}
|
||||
'storage': {'result': True},
|
||||
'rescue': {'result': True}}
|
||||
self.assertEqual(expected, ret)
|
||||
mock_iwdi.assert_called_once_with(self.context, node.instance_info)
|
||||
|
||||
@mock.patch.object(images, 'is_whole_disk_image')
|
||||
def test_validate_driver_interfaces(self, mock_iwdi):
|
||||
mock_iwdi.return_value = False
|
||||
target_raid_config = {'logical_disks': [{'size_gb': 1,
|
||||
'raid_level': '1'}]}
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake', target_raid_config=target_raid_config,
|
||||
network_interface='noop')
|
||||
ret = self.service.validate_driver_interfaces(self.context,
|
||||
node.uuid)
|
||||
reason = ('not supported')
|
||||
expected = {'console': {'result': True},
|
||||
'power': {'result': True},
|
||||
'inspect': {'result': True},
|
||||
'management': {'result': True},
|
||||
'boot': {'result': True},
|
||||
'raid': {'result': True},
|
||||
'deploy': {'result': True},
|
||||
'network': {'result': True},
|
||||
'storage': {'result': True},
|
||||
'rescue': {'reason': reason, 'result': None}}
|
||||
self.assertEqual(expected, ret)
|
||||
mock_iwdi.assert_called_once_with(self.context, node.instance_info)
|
||||
|
||||
|
39
releasenotes/notes/rescue-node-87e3b673c61ef628.yaml
Normal file
39
releasenotes/notes/rescue-node-87e3b673c61ef628.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Adds version 1.38 of the Bare Metal API, which provides supports for
|
||||
rescuing (and unrescuing) a node. This includes:
|
||||
|
||||
* A node in the ``active`` provision state can be rescued via the
|
||||
``GET /v1/nodes/{node_ident}/states/provision`` API, by specifying
|
||||
``rescue`` as the ``target`` value, and a ``rescue_password``
|
||||
value. When the node has been rescued, it will be in the ``rescue``
|
||||
provision state. A rescue ramdisk will be running, configured with
|
||||
the specified ``rescue_password``, and listening with ssh on the
|
||||
tenant network.
|
||||
|
||||
* A node in the ``rescue`` provision state can be unrescued (to the
|
||||
``active`` state) via the
|
||||
``GET /v1/nodes/{node_ident}/states/provision`` API, by specifying
|
||||
``unrescue`` as the ``target`` value.
|
||||
|
||||
* The ``rescue_interface`` field of the node resource. A rescue
|
||||
interface can be set when creating or updating a node.
|
||||
|
||||
* It also exposes ``default_rescue_interface`` and
|
||||
``enable_rescue_interfaces`` fields of the driver resource.
|
||||
|
||||
* Adds new configuration options ``[DEFAULT]/enabled_rescue_interfaces``
|
||||
and ``[DEFAULT]/default_rescue_interface``. Rescue interfaces are
|
||||
enabled via the ``[DEFAULT]/enabled_rescue_interfaces``. A default
|
||||
rescue interface to use when creating or updating nodes can be
|
||||
specified with the ``[DEFAULT]/enabled_rescue_interfaces``.
|
||||
|
||||
* Adds new options ``[conductor]/check_rescue_state_interval`` and
|
||||
``[conductor]/rescue_callback_timeout`` to fail the rescue operation
|
||||
upon timeout, for the nodes that are stuck in the rescue wait state.
|
||||
|
||||
* Adds support for providing separate ``rescuing`` network with its
|
||||
security groups using new options ``[neutron]/rescuing_network`` and
|
||||
``[neutron]/rescuing_network_security_groups`` respectively. It is
|
||||
required to provide ``[neutron]/rescuing_network``.
|
2
tox.ini
2
tox.ini
@ -105,7 +105,7 @@ filename = *.py,app.wsgi
|
||||
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build
|
||||
import-order-style = pep8
|
||||
application-import-names = ironic
|
||||
max-complexity=17
|
||||
max-complexity=18
|
||||
# [H106] Don’t put vim configuration in source files.
|
||||
# [H203] Use assertIs(Not)None to check for None.
|
||||
# [H204] Use assert(Not)Equal to check for equality.
|
||||
|
Loading…
Reference in New Issue
Block a user