From 58d59db30fb5925bd3a7d326338cc57e44df04b5 Mon Sep 17 00:00:00 2001 From: Naohiro Tamura Date: Thu, 28 Jul 2016 11:49:45 +0900 Subject: [PATCH] Generic management I/F for Inject NMI This patch updates the generic management interface and adds a new REST API to support the injection of Non-Masking Interrupts (NMI) for a node. This feature can be used for hardware diagnostics, and actual support depends on a driver. Partial-Bug: #1526226 Change-Id: I08d74f5ccbc386baca1fb29e428fe01924499d45 --- doc/source/dev/webapi-version-history.rst | 5 ++ etc/ironic/policy.json.sample | 2 + ironic/api/controllers/v1/node.py | 38 ++++++++++++ ironic/api/controllers/v1/utils.py | 8 +++ ironic/api/controllers/v1/versions.py | 4 +- ironic/common/policy.py | 3 + ironic/conductor/manager.py | 32 +++++++++- ironic/conductor/rpcapi.py | 22 ++++++- ironic/drivers/base.py | 11 ++++ ironic/tests/unit/api/v1/test_nodes.py | 33 +++++++++++ ironic/tests/unit/api/v1/test_utils.py | 7 +++ ironic/tests/unit/conductor/test_manager.py | 58 +++++++++++++++++++ ironic/tests/unit/conductor/test_rpcapi.py | 6 ++ ironic/tests/unit/drivers/test_base.py | 10 ++++ .../notes/inject-nmi-dacd692b1f259a30.yaml | 5 ++ 15 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 releasenotes/notes/inject-nmi-dacd692b1f259a30.yaml diff --git a/doc/source/dev/webapi-version-history.rst b/doc/source/dev/webapi-version-history.rst index 1b99e7c0b9..4998eac5a4 100644 --- a/doc/source/dev/webapi-version-history.rst +++ b/doc/source/dev/webapi-version-history.rst @@ -2,6 +2,11 @@ REST API Version History ======================== +**1.29** (Ocata) + + Add a new management API to support inject NMI, + 'PUT /v1/nodes/(node_ident)/management/inject_nmi'. + **1.28** (Ocata) Add '/v1/nodes//vifs' endpoint for attach, detach and list of VIFs. diff --git a/etc/ironic/policy.json.sample b/etc/ironic/policy.json.sample index 1b196b2752..df7c8b550f 100644 --- a/etc/ironic/policy.json.sample +++ b/etc/ironic/policy.json.sample @@ -48,6 +48,8 @@ "baremetal:node:vif:attach": "rule:is_admin" # Detach a VIF from a node "baremetal:node:vif:detach": "rule:is_admin" +# Inject NMI for a node +"baremetal:node:inject_nmi": "rule:is_admin" # Retrieve Port records "baremetal:port:get": "rule:is_admin or rule:is_observer" # Create Port records diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 548594782d..d83dcef252 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -250,11 +250,49 @@ class BootDeviceController(rest.RestController): return {'supported_boot_devices': boot_devices} +class InjectNmiController(rest.RestController): + + @METRICS.timer('InjectNmiController.put') + @expose.expose(None, types.uuid_or_name, + status_code=http_client.NO_CONTENT) + def put(self, node_ident): + """Inject NMI for a node. + + Inject NMI (Non Maskable Interrupt) for a node immediately. + + :param node_ident: the UUID or logical name of a node. + :raises: NotFound if requested version of the API doesn't support + inject nmi. + :raises: HTTPForbidden if the policy is not authorized. + :raises: NodeNotFound if the node is not found. + :raises: NodeLocked if the node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management or management.inject_nmi. + :raises: InvalidParameterValue when the wrong driver info is + specified or an invalid boot device is specified. + :raises: MissingParameterValue if missing supplied info. + """ + if not api_utils.allow_inject_nmi(): + raise exception.NotFound() + + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:inject_nmi', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + pecan.request.rpcapi.inject_nmi(pecan.request.context, + rpc_node.uuid, + topic=topic) + + class NodeManagementController(rest.RestController): boot_device = BootDeviceController() """Expose boot_device as a sub-element of management""" + inject_nmi = InjectNmiController() + """Expose inject_nmi as a sub-element of management""" + class ConsoleInfo(base.APIBase): """API representation of the console information for a node.""" diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 3e28705a93..40a9a1369a 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -378,6 +378,14 @@ def allow_soft_power_off(): return pecan.request.version.minor >= versions.MINOR_27_SOFT_POWER_OFF +def allow_inject_nmi(): + """Check if Inject NMI is allowed for the node. + + Version 1.29 of the API allows Inject NMI for the node. + """ + return pecan.request.version.minor >= versions.MINOR_29_INJECT_NMI + + def allow_links_node_states_and_driver_properties(): """Check if links are displayable. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index e889708721..9cb46fe30a 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -59,6 +59,7 @@ BASE_VERSION = 1 # v1.26: Add portgroup.mode and portgroup.properties. # v1.27: Add soft reboot, soft power off and timeout. # v1.28: Add vifs subcontroller to node +# v1.29: Add inject nmi. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -89,11 +90,12 @@ MINOR_25_UNSET_CHASSIS_UUID = 25 MINOR_26_PORTGROUP_MODE_PROPERTIES = 26 MINOR_27_SOFT_POWER_OFF = 27 MINOR_28_VIFS_SUBCONTROLLER = 28 +MINOR_29_INJECT_NMI = 29 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/dev/webapi-version-history.rst with a detailed explanation of # what the version has changed. -MINOR_MAX_VERSION = MINOR_28_VIFS_SUBCONTROLLER +MINOR_MAX_VERSION = MINOR_29_INJECT_NMI # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index c7a30174f5..b2a317bebe 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -127,6 +127,9 @@ node_policies = [ policy.RuleDefault('baremetal:node:vif:detach', 'rule:is_admin', description='Detach a VIF from a node'), + policy.RuleDefault('baremetal:node:inject_nmi', + 'rule:is_admin', + description='Inject NMI for a node'), ] port_policies = [ diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 9161ef718e..fe3b26b3ca 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -83,7 +83,7 @@ class ConductorManager(base_manager.BaseConductorManager): """Ironic Conductor manager main class.""" # NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's. - RPC_API_VERSION = '1.39' + RPC_API_VERSION = '1.40' target = messaging.Target(version=RPC_API_VERSION) @@ -2171,6 +2171,36 @@ class ConductorManager(base_manager.BaseConductorManager): task.driver.management.validate(task) return task.driver.management.get_boot_device(task) + @METRICS.timer('ConductorManager.inject_nmi') + @messaging.expected_exceptions(exception.NodeLocked, + exception.UnsupportedDriverExtension, + exception.InvalidParameterValue) + def inject_nmi(self, context, node_id): + """Inject NMI for a node. + + Inject NMI (Non Maskable Interrupt) for a node immediately. + + :param context: request context. + :param node_id: node id or uuid. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management or management.inject_nmi. + :raises: InvalidParameterValue when the wrong driver info is + specified or an invalid boot device is specified. + :raises: MissingParameterValue if missing supplied info. + """ + LOG.debug('RPC inject_nmi called for node %s', node_id) + + with task_manager.acquire(context, node_id, + purpose='inject nmi') as task: + node = task.node + if not getattr(task.driver, 'management', None): + raise exception.UnsupportedDriverExtension( + driver=node.driver, extension='management') + task.driver.management.validate(task) + + task.driver.management.inject_nmi(task) + @METRICS.timer('ConductorManager.get_supported_boot_devices') @messaging.expected_exceptions(exception.NodeLocked, exception.UnsupportedDriverExtension, diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index d6d433edfc..9eae0fb4d8 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -86,11 +86,12 @@ class ConductorAPI(object): | 1.37 - Added destroy_volume_target and update_volume_target | 1.38 - Added vif_attach, vif_detach, vif_list | 1.39 - Added timeout optional parameter to change_node_power_state + | 1.40 - Added inject_nmi """ # NOTE(rloo): This must be in sync with manager.ConductorManager's. - RPC_API_VERSION = '1.39' + RPC_API_VERSION = '1.40' def __init__(self, topic=None): super(ConductorAPI, self).__init__() @@ -555,6 +556,25 @@ class ConductorAPI(object): cctxt = self.client.prepare(topic=topic or self.topic, version='1.17') return cctxt.call(context, 'get_boot_device', node_id=node_id) + def inject_nmi(self, context, node_id, topic=None): + """Inject NMI for a node. + + Inject NMI (Non Maskable Interrupt) for a node immediately. + Be aware that not all drivers support this. + + :param context: request context. + :param node_id: node id or uuid. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management or management.inject_nmi. + :raises: InvalidParameterValue when the wrong driver info is + specified or an invalid boot device is specified. + :raises: MissingParameterValue if missing supplied info. + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.40') + return cctxt.call(context, 'inject_nmi', node_id=node_id) + def get_supported_boot_devices(self, context, node_id, topic=None): """Get the list of supported devices. diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index de4095221d..f8d2cdc94a 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -822,6 +822,17 @@ class ManagementInterface(BaseInterface): } """ + def inject_nmi(self, task): + """Inject NMI, Non Maskable Interrupt. + + Inject NMI (Non Maskable Interrupt) for a node immediately. + + :param task: A TaskManager instance containing the node to act on. + :raises: UnsupportedDriverExtension + """ + raise exception.UnsupportedDriverExtension( + driver=task.node.driver, extension='inject_nmi') + class InspectInterface(BaseInterface): """Interface for inspection-related actions.""" diff --git a/ironic/tests/unit/api/v1/test_nodes.py b/ironic/tests/unit/api/v1/test_nodes.py index 6fe96c09d8..98c05a3cbd 100644 --- a/ironic/tests/unit/api/v1/test_nodes.py +++ b/ironic/tests/unit/api/v1/test_nodes.py @@ -3063,6 +3063,39 @@ class TestPut(test_api_base.BaseApiTest): self.assertEqual('application/json', ret.content_type) self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + @mock.patch.object(rpcapi.ConductorAPI, 'inject_nmi') + def test_inject_nmi(self, mock_inject_nmi): + ret = self.put_json('/nodes/%s/management/inject_nmi' + % self.node.uuid, {}, + headers={api_base.Version.string: "1.29"}) + self.assertEqual(http_client.NO_CONTENT, ret.status_code) + self.assertEqual(b'', ret.body) + mock_inject_nmi.assert_called_once_with(mock.ANY, self.node.uuid, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'inject_nmi') + def test_inject_nmi_not_allowed(self, mock_inject_nmi): + ret = self.put_json('/nodes/%s/management/inject_nmi' + % self.node.uuid, {}, + headers={api_base.Version.string: "1.28"}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + self.assertTrue(ret.json['error_message']) + self.assertFalse(mock_inject_nmi.called) + + @mock.patch.object(rpcapi.ConductorAPI, 'inject_nmi') + def test_inject_nmi_not_supported(self, mock_inject_nmi): + mock_inject_nmi.side_effect = exception.UnsupportedDriverExtension( + extension='management', driver='test-driver') + ret = self.put_json('/nodes/%s/management/inject_nmi' + % self.node.uuid, {}, + headers={api_base.Version.string: "1.29"}, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + self.assertTrue(ret.json['error_message']) + mock_inject_nmi.assert_called_once_with(mock.ANY, self.node.uuid, + topic='test-topic') + def _test_set_node_maintenance_mode(self, mock_update, mock_get, reason, node_ident, is_by_name=False): request_body = {} diff --git a/ironic/tests/unit/api/v1/test_utils.py b/ironic/tests/unit/api/v1/test_utils.py index 0c9c22c3a6..54862be031 100644 --- a/ironic/tests/unit/api/v1/test_utils.py +++ b/ironic/tests/unit/api/v1/test_utils.py @@ -280,6 +280,13 @@ class TestApiUtils(base.TestCase): def test_check_allow_unknown_verbs(self, mock_request): utils.check_allow_management_verbs('rebuild') + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_inject_nmi(self, mock_request): + mock_request.version.minor = 29 + self.assertTrue(utils.allow_inject_nmi()) + mock_request.version.minor = 28 + self.assertFalse(utils.allow_inject_nmi()) + @mock.patch.object(pecan, 'request', spec_set=['version']) def test_allow_links_node_states_and_driver_properties(self, mock_request): mock_request.version.minor = 14 diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 7a023711c0..1b65fdc037 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -3380,6 +3380,64 @@ class UpdatePortTestCase(mgr_utils.ServiceSetUpMixin, # Compare true exception hidden by @messaging.expected_exceptions self.assertEqual(exception.InvalidParameterValue, exc.exc_info[0]) + def test_inject_nmi(self): + node = obj_utils.create_test_node(self.context, driver='fake') + with mock.patch.object(self.driver.management, 'validate') as mock_val: + with mock.patch.object(self.driver.management, + 'inject_nmi') as mock_sbd: + self.service.inject_nmi(self.context, node.uuid) + mock_val.assert_called_once_with(mock.ANY) + mock_sbd.assert_called_once_with(mock.ANY) + + def test_inject_nmi_node_locked(self): + node = obj_utils.create_test_node(self.context, driver='fake', + reservation='fake-reserv') + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.inject_nmi, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.NodeLocked, exc.exc_info[0]) + + def test_inject_nmi_not_supported(self): + node = obj_utils.create_test_node(self.context, driver='fake') + # null the management interface + self.driver.management = None + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.inject_nmi, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.UnsupportedDriverExtension, + exc.exc_info[0]) + + def test_inject_nmi_validate_invalid_param(self): + node = obj_utils.create_test_node(self.context, driver='fake') + with mock.patch.object(self.driver.management, 'validate') as mock_val: + mock_val.side_effect = exception.InvalidParameterValue('error') + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.inject_nmi, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.InvalidParameterValue, exc.exc_info[0]) + + def test_inject_nmi_validate_missing_param(self): + node = obj_utils.create_test_node(self.context, driver='fake') + with mock.patch.object(self.driver.management, 'validate') as mock_val: + mock_val.side_effect = exception.MissingParameterValue('error') + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.inject_nmi, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.MissingParameterValue, exc.exc_info[0]) + + def test_inject_nmi_not_implemented(self): + node = obj_utils.create_test_node(self.context, driver='fake') + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.inject_nmi, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.UnsupportedDriverExtension, + exc.exc_info[0]) + def test_get_supported_boot_devices(self): node = obj_utils.create_test_node(self.context, driver='fake') bootdevs = self.service.get_supported_boot_devices(self.context, diff --git a/ironic/tests/unit/conductor/test_rpcapi.py b/ironic/tests/unit/conductor/test_rpcapi.py index df7481ee08..1feec62d1a 100644 --- a/ironic/tests/unit/conductor/test_rpcapi.py +++ b/ironic/tests/unit/conductor/test_rpcapi.py @@ -276,6 +276,12 @@ class RPCAPITestCase(base.DbTestCase): version='1.17', node_id=self.fake_node['uuid']) + def test_inject_nmi(self): + self._test_rpcapi('inject_nmi', + 'call', + version='1.40', + node_id=self.fake_node['uuid']) + def test_get_supported_boot_devices(self): self._test_rpcapi('get_supported_boot_devices', 'call', diff --git a/ironic/tests/unit/drivers/test_base.py b/ironic/tests/unit/drivers/test_base.py index 50c4627684..afbf645377 100644 --- a/ironic/tests/unit/drivers/test_base.py +++ b/ironic/tests/unit/drivers/test_base.py @@ -498,3 +498,13 @@ class NetworkInterfaceTestCase(base.TestCase): network.get_current_vif(mock_task, port) mock_gcv.assert_called_once_with(mock_task, port) self.assertTrue(mock_warn.called) + + +class TestManagementInterface(base.TestCase): + + def test_inject_nmi_default_impl(self): + management = fake.FakeManagement() + task_mock = mock.MagicMock(spec_set=['node']) + + self.assertRaises(exception.UnsupportedDriverExtension, + management.inject_nmi, task_mock) diff --git a/releasenotes/notes/inject-nmi-dacd692b1f259a30.yaml b/releasenotes/notes/inject-nmi-dacd692b1f259a30.yaml new file mode 100644 index 0000000000..95bb57fd5e --- /dev/null +++ b/releasenotes/notes/inject-nmi-dacd692b1f259a30.yaml @@ -0,0 +1,5 @@ +--- +features: + - Add support for the injection of Non-Masking Interrupts (NMI) for + a node in Ironic API 1.29. This feature can be used for hardware + diagnostics, and actual support depends on a driver.