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:
Jay Faulkner 2016-08-03 17:11:33 -07:00 committed by Julia Kreger
parent e95f3de5c9
commit 49fabe6d7b
14 changed files with 454 additions and 43 deletions

View File

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

View File

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

View File

@ -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'}]

View File

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

View File

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

View File

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

View File

@ -121,7 +121,7 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.37',
'api': '1.38',
'rpc': '1.44',
'objects': {
'Node': ['1.23'],

View File

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

View File

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

View File

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

View File

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

View File

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

View 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``.

View File

@ -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] Dont put vim configuration in source files.
# [H203] Use assertIs(Not)None to check for None.
# [H204] Use assert(Not)Equal to check for equality.