From 263fd021b23889b139a7971d93a18268c6b0be71 Mon Sep 17 00:00:00 2001 From: Ilya Etingof Date: Wed, 10 Apr 2019 08:14:22 +0200 Subject: [PATCH] Add indicators REST API endpoints Added REST API endpoints for indicator management: * GET /v1/nodes//management/indicators` to list all available indicators names for each of the hardware component. * GET /v1/nodes//management/indicators/ to retrieve the state of given indicator. * PUT /v1/nodes//management/indicators/` change state of the desired indicator. This implementation slightly deviates from the original spec in part of having component name in the URL - this implementation flattens component out. The spec: https://review.opendev.org/#/c/655685/7/specs/approved/expose-hardware-indicators.rst Change-Id: I3a36f58b12487e18a6898aef6b077d4221f8a5b8 Story: 2005342 Task: 30291 --- .../contributor/webapi-version-history.rst | 14 ++ ironic/api/controllers/v1/node.py | 181 ++++++++++++++ ironic/api/controllers/v1/versions.py | 8 +- ironic/common/policy.py | 17 ++ ironic/common/release_mappings.py | 4 +- ironic/conductor/manager.py | 128 +++++++++- ironic/conductor/rpcapi.py | 87 ++++++- .../unit/api/controllers/v1/test_node.py | 229 +++++++++++++++++- ironic/tests/unit/conductor/test_manager.py | 52 ++++ ironic/tests/unit/conductor/test_rpcapi.py | 25 ++ .../add-indicator-api-8c816b3828e6b43b.yaml | 6 + 11 files changed, 743 insertions(+), 8 deletions(-) create mode 100644 releasenotes/notes/add-indicator-api-8c816b3828e6b43b.yaml diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index bb83c823d4..22ad338c2e 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,20 @@ REST API Version History ======================== +1.63 (Ussuri, master) +--------------------- + +Added the following new endpoints for indicator management: + +* ``GET /v1/nodes//management/indicators`` to list all + available indicators names for each of the hardware component. + Currently known components are: ``chassis``, ``system``, ``disk``, ``power`` + and ``nic``. +* ``GET /v1/nodes//management/indicators//`` + to retrieve all indicators and their states for the hardware component. +* ``PUT /v1/nodes//management/indicators//`` + change state of the desired indicators of the component. + 1.62 (Ussuri, master) --------------------- diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 7917040095..caa73267f4 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -266,6 +266,184 @@ class BootDeviceController(rest.RestController): return {'supported_boot_devices': boot_devices} +class IndicatorAtComponent(object): + + def __init__(self, **kwargs): + name = kwargs.get('name') + component = kwargs.get('component') + unique_name = kwargs.get('unique_name') + + if name and component: + self.unique_name = name + '@' + component + self.name = name + self.component = component + + elif unique_name: + try: + index = unique_name.index('@') + + except ValueError: + raise exception.InvalidParameterValue( + _('Malformed indicator name "%s"') % unique_name) + + self.component = unique_name[index + 1:] + self.name = unique_name[:index] + self.unique_name = unique_name + + else: + raise exception.MissingParameterValue( + _('Missing indicator name "%s"')) + + +class IndicatorState(base.APIBase): + """API representation of indicator state.""" + + state = wsme.wsattr(wtypes.text) + + def __init__(self, **kwargs): + self.state = kwargs.get('state') + + +class Indicator(base.APIBase): + """API representation of an indicator.""" + + name = wsme.wsattr(wtypes.text) + + component = wsme.wsattr(wtypes.text) + + readonly = types.BooleanType() + + states = wtypes.ArrayType(str) + + links = wsme.wsattr([link.Link], readonly=True) + + def __init__(self, **kwargs): + self.name = kwargs.get('name') + self.component = kwargs.get('component') + self.readonly = kwargs.get('readonly', True) + self.states = kwargs.get('states', []) + + @staticmethod + def _convert_with_links(node_uuid, indicator, url): + """Add links to the indicator.""" + indicator.links = [ + link.Link.make_link( + 'self', url, 'nodes', + '%s/management/indicators/%s' % ( + node_uuid, indicator.name)), + link.Link.make_link( + 'bookmark', url, 'nodes', + '%s/management/indicators/%s' % ( + node_uuid, indicator.name), + bookmark=True)] + return indicator + + @classmethod + def convert_with_links(cls, node_uuid, rpc_component, rpc_name, + **rpc_fields): + """Add links to the indicator.""" + indicator = Indicator( + component=rpc_component, name=rpc_name, **rpc_fields) + return cls._convert_with_links( + node_uuid, indicator, pecan.request.host_url) + + +class IndicatorsCollection(wtypes.Base): + """API representation of the indicators for a node.""" + + indicators = [Indicator] + """Node indicators list""" + + @staticmethod + def collection_from_dict(node_ident, indicators): + col = IndicatorsCollection() + + indicator_list = [] + for component, names in indicators.items(): + for name, fields in names.items(): + indicator_at_component = IndicatorAtComponent( + component=component, name=name) + indicator = Indicator.convert_with_links( + node_ident, component, indicator_at_component.unique_name, + **fields) + indicator_list.append(indicator) + col.indicators = indicator_list + return col + + +class IndicatorController(rest.RestController): + + @METRICS.timer('IndicatorController.put') + @expose.expose(None, types.uuid_or_name, wtypes.text, wtypes.text, + status_code=http_client.NO_CONTENT) + def put(self, node_ident, indicator, state): + """Set node hardware component indicator to the desired state. + + :param node_ident: the UUID or logical name of a node. + :param indicator: Indicator ID (as reported by + `get_supported_indicators`). + :param state: Indicator state, one of + mod:`ironic.common.indicator_states`. + + """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:set_indicator_state', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + indicator_at_component = IndicatorAtComponent(unique_name=indicator) + pecan.request.rpcapi.set_indicator_state( + pecan.request.context, rpc_node.uuid, + indicator_at_component.component, indicator_at_component.name, + state, topic=topic) + + @METRICS.timer('IndicatorController.get_one') + @expose.expose(IndicatorState, types.uuid_or_name, wtypes.text) + def get_one(self, node_ident, indicator): + """Get node hardware component indicator and its state. + + :param node_ident: the UUID or logical name of a node. + :param indicator: Indicator ID (as reported by + `get_supported_indicators`). + :returns: a dict with the "state" key and one of + mod:`ironic.common.indicator_states` as a value. + """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:get_indicator_state', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + indicator_at_component = IndicatorAtComponent(unique_name=indicator) + state = pecan.request.rpcapi.get_indicator_state( + pecan.request.context, rpc_node.uuid, + indicator_at_component.component, indicator_at_component.name, + topic=topic) + return IndicatorState(state=state) + + @METRICS.timer('IndicatorController.get_all') + @expose.expose(IndicatorsCollection, types.uuid_or_name, wtypes.text, + ignore_extra_args=True) + def get_all(self, node_ident): + """Get node hardware components and their indicators. + + :param node_ident: the UUID or logical name of a node. + :returns: A json object of hardware components + (:mod:`ironic.common.components`) as keys with indicator IDs + (from `get_supported_indicators`) as values. + + """ + cdict = pecan.request.context.to_policy_values() + policy.authorize('baremetal:node:get_indicator_state', cdict, cdict) + + rpc_node = api_utils.get_rpc_node(node_ident) + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + indicators = pecan.request.rpcapi.get_supported_indicators( + pecan.request.context, rpc_node.uuid, topic=topic) + + return IndicatorsCollection.collection_from_dict( + node_ident, indicators) + + class InjectNmiController(rest.RestController): @METRICS.timer('InjectNmiController.put') @@ -308,6 +486,9 @@ class NodeManagementController(rest.RestController): inject_nmi = InjectNmiController() """Expose inject_nmi as a sub-element of management""" + indicators = IndicatorController() + """Expose indicators as a sub-element of management""" + class ConsoleInfo(base.Base): """API representation of the console information for a node.""" diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index f39c077b28..901f91dbcc 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -23,8 +23,8 @@ CONF = cfg.CONF BASE_VERSION = 1 # Here goes a short log of changes in every version. -# Refer to doc/source/dev/webapi-version-history.rst for a detailed explanation -# of what each version contains. +# Refer to doc/source/contributor/webapi-version-history.rst for a detailed +# explanation of what each version contains. # # v1.0: corresponds to Juno API, not supported since Kilo # v1.1: API at the point in time when versioning support was added, @@ -100,6 +100,7 @@ BASE_VERSION = 1 # v1.60: Add owner to the allocation object. # v1.61: Add retired and retired_reason to the node object. # v1.62: Add agent_token support for agent communication. +# v1.63: Add support for indicators MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -164,6 +165,7 @@ MINOR_59_CONFIGDRIVE_VENDOR_DATA = 59 MINOR_60_ALLOCATION_OWNER = 60 MINOR_61_NODE_RETIRED = 61 MINOR_62_AGENT_TOKEN = 62 +MINOR_63_INDICATORS = 63 # When adding another version, update: # - MINOR_MAX_VERSION @@ -171,7 +173,7 @@ MINOR_62_AGENT_TOKEN = 62 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_62_AGENT_TOKEN +MINOR_MAX_VERSION = MINOR_63_INDICATORS # 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 828fbef739..9d0c1f7a33 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -157,6 +157,23 @@ node_policies = [ [{'path': '/nodes/{node_ident}/management/boot_device', 'method': 'PUT'}]), + policy.DocumentedRuleDefault( + 'baremetal:node:get_indicator_state', + 'rule:is_admin or rule:is_observer', + 'Retrieve Node indicators and their states', + [{'path': '/nodes/{node_ident}/management/indicators/' + '{component}/{indicator}', + 'method': 'GET'}, + {'path': '/nodes/{node_ident}/management/indicators', + 'method': 'GET'}]), + policy.DocumentedRuleDefault( + 'baremetal:node:set_indicator_state', + 'rule:is_admin', + 'Change Node indicator state', + [{'path': '/nodes/{node_ident}/management/indicators/' + '{component}/{indicator}', + 'method': 'PUT'}]), + policy.DocumentedRuleDefault( 'baremetal:node:inject_nmi', 'rule:is_admin', diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 4dfc233870..1190d51698 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -214,8 +214,8 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.62', - 'rpc': '1.49', + 'api': '1.63', + 'rpc': '1.50', 'objects': { 'Allocation': ['1.1'], 'Node': ['1.33', '1.32'], diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index aca403313b..6dd31b5345 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -90,7 +90,7 @@ class ConductorManager(base_manager.BaseConductorManager): # NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's. # NOTE(pas-ha): This also must be in sync with # ironic.common.release_mappings.RELEASE_MAPPING['master'] - RPC_API_VERSION = '1.49' + RPC_API_VERSION = '1.50' target = messaging.Target(version=RPC_API_VERSION) @@ -2784,6 +2784,132 @@ class ConductorManager(base_manager.BaseConductorManager): purpose=lock_purpose) as task: return task.driver.management.get_supported_boot_devices(task) + @METRICS.timer('ConductorManager.set_indicator_state') + @messaging.expected_exceptions(exception.NodeLocked, + exception.UnsupportedDriverExtension, + exception.InvalidParameterValue) + def set_indicator_state(self, context, node_id, component, + indicator, state): + """Set node hardware components indicator to the desired state. + + :param context: request context. + :param node_id: node id or uuid. + :param component: The hardware component, one of + :mod:`ironic.common.components`. + :param indicator: Indicator IDs, as + reported by `get_supported_indicators`) + :param state: Indicator state, one of + mod:`ironic.common.indicator_states`. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :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 set_indicator_state called for node %(node)s with ' + 'component %(component)s, indicator %(indicator)s and state ' + '%(state)s', {'node': node_id, 'component': component, + 'indicator': indicator, 'state': state}) + with task_manager.acquire(context, node_id, + purpose='setting indicator state') as task: + task.driver.management.validate(task) + task.driver.management.set_indicator_state( + task, component, indicator, state) + + @METRICS.timer('ConductorManager.get_indicator_states') + @messaging.expected_exceptions(exception.NodeLocked, + exception.UnsupportedDriverExtension, + exception.InvalidParameterValue) + def get_indicator_state(self, context, node_id, component, indicator): + """Get node hardware component indicator state. + + :param context: request context. + :param node_id: node id or uuid. + :param component: The hardware component, one of + :mod:`ironic.common.components`. + :param indicator: Indicator IDs, as + reported by `get_supported_indicators`) + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :raises: InvalidParameterValue when the wrong driver info is + specified. + :raises: MissingParameterValue if missing supplied info. + :returns: Indicator state, one of + mod:`ironic.common.indicator_states`. + + """ + LOG.debug('RPC get_indicator_states called for node %s', node_id) + with task_manager.acquire(context, node_id, + purpose='getting indicators states') as task: + task.driver.management.validate(task) + return task.driver.management.get_indicator_state( + task, component, indicator) + + @METRICS.timer('ConductorManager.get_supported_indicators') + @messaging.expected_exceptions(exception.NodeLocked, + exception.UnsupportedDriverExtension, + exception.InvalidParameterValue) + def get_supported_indicators(self, context, node_id, component=None): + """Get node hardware components and their indicators. + + :param context: request context. + :param node_id: node id or uuid. + :param component: If not `None`, return indicator information + for just this component, otherwise return indicators for + all existing components. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :raises: InvalidParameterValue when the wrong driver info is + specified. + :raises: MissingParameterValue if missing supplied info. + :returns: A dictionary of hardware components + (:mod:`ironic.common.components`) as keys with indicator IDs + as values. + + :: + + { + 'chassis': { + 'enclosure-0': { + "readonly": true, + "states": [ + "OFF", + "ON" + ] + } + }, + 'system': + 'blade-A': { + "readonly": true, + "states": [ + "OFF", + "ON" + ] + } + }, + 'drive': + 'ssd0': { + "readonly": true, + "states": [ + "OFF", + "ON" + ] + } + } + } + + """ + LOG.debug('RPC get_supported_indicators called for node %s', node_id) + lock_purpose = 'getting supported indicators' + with task_manager.acquire(context, node_id, shared=True, + purpose=lock_purpose) as task: + return task.driver.management.get_supported_indicators( + task, component) + @METRICS.timer('ConductorManager.inspect_hardware') @messaging.expected_exceptions(exception.NoFreeConductorWorker, exception.NodeLocked, diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index 84054c36a0..f3a64824b5 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -101,13 +101,15 @@ class ConductorAPI(object): | 1.48 - Added allocation API | 1.49 - Added get_node_with_token and agent_token argument to heartbeat + | 1.50 - Added set_indicator_state, get_indicator_state and + | get_supported_indicators. """ # NOTE(rloo): This must be in sync with manager.ConductorManager's. # NOTE(pas-ha): This also must be in sync with # ironic.common.release_mappings.RELEASE_MAPPING['master'] - RPC_API_VERSION = '1.49' + RPC_API_VERSION = '1.50' def __init__(self, topic=None): super(ConductorAPI, self).__init__() @@ -713,6 +715,89 @@ class ConductorAPI(object): return cctxt.call(context, 'get_supported_boot_devices', node_id=node_id) + def set_indicator_state(self, context, node_id, component, + indicator, state, topic=None): + """Set node hardware components indicator to the desired state. + + :param context: request context. + :param node_id: node id or uuid. + :param component: The hardware component, one of + :mod:`ironic.common.components`. + :param indicator: Indicator IDs, as + reported by `get_supported_indicators`) + :param state: Indicator state, one of + mod:`ironic.common.indicator_states`. + :param topic: RPC topic. Defaults to self.topic. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :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.50') + return cctxt.call(context, 'set_indicator_state', node_id=node_id, + component=component, indicator=indicator, + state=state) + + def get_indicator_state(self, context, node_id, component, indicator, + topic=None): + """Get node hardware component indicator state. + + :param context: request context. + :param node_id: node id or uuid. + :param component: The hardware component, one of + :mod:`ironic.common.components`. + :param indicator: Indicator IDs, as + reported by `get_supported_indicators`) + :param topic: RPC topic. Defaults to self.topic. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :raises: InvalidParameterValue when the wrong driver info is + specified. + :raises: MissingParameterValue if missing supplied info. + :returns: Indicator state, one of + mod:`ironic.common.indicator_states`. + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.50') + return cctxt.call(context, 'get_indicator_state', node_id=node_id, + component=component, indicator=indicator) + + def get_supported_indicators(self, context, node_id, + component=None, topic=None): + """Get node hardware components and their indicators. + + :param context: request context. + :param node_id: node id or uuid. + :param component: The hardware component, one of + :mod:`ironic.common.components`. + :param topic: RPC topic. Defaults to self.topic. + :raises: NodeLocked if node is locked by another conductor. + :raises: UnsupportedDriverExtension if the node's driver doesn't + support management. + :raises: InvalidParameterValue when the wrong driver info is + specified. + :raises: MissingParameterValue if missing supplied info. + :returns: A dictionary of hardware components + (:mod:`ironic.common.components`) as keys with indicator IDs + as values. + + :: + + { + 'chassis': ['enclosure-0'], + 'system': ['blade-A'] + 'drive': ['ssd0'] + } + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.50') + return cctxt.call(context, 'get_supported_indicators', node_id=node_id, + component=component) + def inspect_hardware(self, context, node_id, topic=None): """Signals the conductor service to perform hardware introspection. diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 7ad2ebe824..7fa5098457 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -33,8 +33,10 @@ from ironic.api.controllers.v1 import notification_utils from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.common import boot_devices +from ironic.common import components from ironic.common import driver_factory from ironic.common import exception +from ironic.common import indicator_states from ironic.common import policy from ironic.common import states from ironic.conductor import rpcapi @@ -2113,6 +2115,152 @@ class TestListNodes(test_api_base.BaseApiTest): self.assertEqual("******", data["driver_info"]["ssh_password"]) self.assertEqual("******", data["driver_info"]["ssh_key_contents"]) + @mock.patch.object(rpcapi.ConductorAPI, 'get_indicator_state') + def test_get_indicator_state(self, mock_gis): + node = obj_utils.create_test_node(self.context) + expected_data = { + 'state': indicator_states.ON + } + mock_gis.return_value = indicator_states.ON + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + data = self.get_json( + '/nodes/%s/management/indicators' + '/%s' % (node.uuid, indicator_name)) + self.assertEqual(expected_data, data) + mock_gis.assert_called_once_with( + mock.ANY, node.uuid, component, indicator_id, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'get_indicator_state') + def test_get_indicator_state_versioning(self, mock_gis): + node = obj_utils.create_test_node(self.context, name='spam') + expected_data = { + 'state': indicator_states.ON + } + mock_gis.return_value = indicator_states.ON + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + data = self.get_json( + '/nodes/%s/management/indicators' + '/%s' % (node.uuid, indicator_name), + headers={api_base.Version.string: "1.63"}) + self.assertEqual(expected_data, data) + mock_gis.assert_called_once_with( + mock.ANY, node.uuid, component, indicator_id, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'get_indicator_state') + def test_get_indicator_state_iface_not_supported(self, mock_gis): + node = obj_utils.create_test_node(self.context) + mock_gis.side_effect = exception.UnsupportedDriverExtension( + extension='management', driver='test-driver') + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + ret = self.get_json( + '/nodes/%s/management/indicators' + '/%s' % (node.uuid, indicator_name), + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + self.assertTrue(ret.json['error_message']) + mock_gis.assert_called_once_with( + mock.ANY, node.uuid, component, indicator_id, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'get_supported_indicators') + def test_get_supported_indicators(self, mock_gsi): + mock_gsi.return_value = { + components.CHASSIS: { + 'led': { + 'readonly': True, + 'states': [ + 'OFF', + 'ON' + ] + } + } + } + node = obj_utils.create_test_node(self.context) + + expected_data = { + 'indicators': [ + {'component': 'chassis', + 'name': 'led@chassis', + 'readonly': True, + 'states': ['OFF', 'ON'], + 'links': [ + {'href': 'http://localhost/v1/nodes/1be26c0b-03f2-4d2e' + '-ae87-c02d7f33c123/management/indicators/' + 'led@chassis', + 'rel': 'self'}, + {'href': 'http://localhost/nodes/1be26c0b-03f2-4d2e-ae' + '87-c02d7f33c123/management/indicators/' + 'led@chassis', + 'rel': 'bookmark'}]} + ] + } + + data = self.get_json('/nodes/%s/management/indicators' + % node.uuid) + self.assertEqual(expected_data, data) + mock_gsi.assert_called_once_with( + mock.ANY, node.uuid, topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'get_supported_indicators') + def test_get_supported_indicators_versioning(self, mock_gsi): + mock_gsi.return_value = { + components.CHASSIS: { + 'led': { + 'readonly': True, + 'states': [ + 'OFF', + 'ON' + ] + } + } + } + node = obj_utils.create_test_node(self.context) + + expected_data = { + 'indicators': [ + {'component': 'chassis', + 'name': 'led@chassis', + 'readonly': True, + 'states': ['OFF', 'ON'], + 'links': [ + {'href': 'http://localhost/v1/nodes/1be26c0b-03f2-4d2e' + '-ae87-c02d7f33c123/management/indicators/' + 'led@chassis', + 'rel': 'self'}, + {'href': 'http://localhost/nodes/1be26c0b-03f2-4d2e-ae' + '87-c02d7f33c123/management/indicators/' + 'led@chassis', + 'rel': 'bookmark'}]} + ] + } + + data = self.get_json('/nodes/%s/management/indicators' + % node.uuid, + headers={api_base.Version.string: "1.63"}) + self.assertEqual(expected_data, data) + mock_gsi.assert_called_once_with( + mock.ANY, node.uuid, topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'get_supported_indicators') + def test_get_supported_indicators_iface_not_supported(self, mock_gsi): + node = obj_utils.create_test_node(self.context) + mock_gsi.side_effect = exception.UnsupportedDriverExtension( + extension='management', driver='test-driver') + ret = self.get_json('/nodes/%s/management/indicators' % + node.uuid, expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + self.assertTrue(ret.json['error_message']) + mock_gsi.assert_called_once_with( + mock.ANY, node.uuid, topic='test-topic') + class TestPatch(test_api_base.BaseApiTest): @@ -4657,7 +4805,7 @@ class TestPut(test_api_base.BaseApiTest): ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, {'target': states.ACTIVE, 'configdrive': fake_cd}, - headers={api_base.Version.string: '1.59'}) + headers={api_base.Version.string: '1.60'}) self.assertEqual(http_client.ACCEPTED, ret.status_code) self.assertEqual(b'', ret.body) self.mock_dnd.assert_called_once_with(context=mock.ANY, @@ -5535,6 +5683,85 @@ class TestPut(test_api_base.BaseApiTest): headers={api_base.Version.string: "1.41"}) self.assertEqual(http_client.ACCEPTED, ret.status_code) + @mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state') + def test_set_indicator_state(self, mock_sis): + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + state = indicator_states.ON + ret = self.put_json( + '/nodes/%s/management/indicators' + '/%s' % (self.node.uuid, indicator_name), + {'state': state}) + self.assertEqual(http_client.NO_CONTENT, ret.status_code) + self.assertEqual(b'', ret.body) + mock_sis.assert_called_once_with( + mock.ANY, self.node.uuid, component, indicator_id, state, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state') + def test_set_indicator_state_versioning(self, mock_sis): + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + state = indicator_states.ON + ret = self.put_json( + '/nodes/%s/management/indicators' + '/%s' % (self.node.uuid, indicator_name), + {'state': state}, headers={api_base.Version.string: "1.63"}) + + self.assertEqual(http_client.NO_CONTENT, ret.status_code) + self.assertEqual(b'', ret.body) + mock_sis.assert_called_once_with( + mock.ANY, self.node.uuid, component, indicator_id, state, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state') + def test_set_indicator_state_not_supported(self, mock_sis): + mock_sis.side_effect = exception.UnsupportedDriverExtension( + extension='management', driver='test-driver') + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + state = indicator_states.ON + ret = self.put_json( + '/nodes/%s/management/indicators' + '/%s' % (self.node.uuid, indicator_name), + {'state': state}, expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + self.assertTrue(ret.json['error_message']) + mock_sis.assert_called_once_with( + mock.ANY, self.node.uuid, component, indicator_id, state, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state') + def test_set_indicator_state_qs(self, mock_sis): + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + state = indicator_states.ON + ret = self.put_json( + '/nodes/%s/management/indicators/%s?' + 'state=%s' % (self.node.uuid, indicator_name, state), {}) + self.assertEqual(http_client.NO_CONTENT, ret.status_code) + self.assertEqual(b'', ret.body) + mock_sis.assert_called_once_with( + mock.ANY, self.node.uuid, component, indicator_id, state, + topic='test-topic') + + @mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state') + def test_set_indicator_state_invalid_value(self, mock_sis): + mock_sis.side_effect = exception.InvalidParameterValue('error') + component = components.SYSTEM + indicator_id = 'led' + indicator_name = indicator_id + '@' + component + ret = self.put_json( + '/nodes/%s/management/indicators/%s?' + 'state=glow' % (self.node.uuid, indicator_name), {}, + expect_errors=True) + self.assertEqual('application/json', ret.content_type) + self.assertEqual(http_client.BAD_REQUEST, ret.status_code) + class TestCheckCleanSteps(base.TestCase): def test__check_clean_steps_not_list(self): diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 285317f31b..a82ad5ea4f 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -33,9 +33,11 @@ from oslo_versionedobjects import base as ovo_base from oslo_versionedobjects import fields from ironic.common import boot_devices +from ironic.common import components from ironic.common import driver_factory from ironic.common import exception from ironic.common import images +from ironic.common import indicator_states from ironic.common import nova from ironic.common import states from ironic.conductor import cleaning @@ -4068,6 +4070,56 @@ class BootDeviceTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): self.assertEqual([boot_devices.PXE], bootdevs) +@mgr_utils.mock_record_keepalive +class IndicatorsTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): + + @mock.patch.object(fake.FakeManagement, 'set_indicator_state', + autospec=True) + @mock.patch.object(fake.FakeManagement, 'validate', autospec=True) + def test_set_indicator_state(self, mock_val, mock_sbd): + node = obj_utils.create_test_node(self.context, driver='fake-hardware') + self.service.set_indicator_state( + self.context, node.uuid, components.CHASSIS, + 'led', indicator_states.ON) + mock_val.assert_called_once_with(mock.ANY, mock.ANY) + mock_sbd.assert_called_once_with( + mock.ANY, mock.ANY, components.CHASSIS, 'led', indicator_states.ON) + + def test_get_indicator_state(self): + node = obj_utils.create_test_node(self.context, driver='fake-hardware') + state = self.service.get_indicator_state( + self.context, node.uuid, components.CHASSIS, 'led-0') + expected = indicator_states.ON + self.assertEqual(expected, state) + + def test_get_supported_indicators(self): + node = obj_utils.create_test_node(self.context, driver='fake-hardware') + indicators = self.service.get_supported_indicators( + self.context, node.uuid) + expected = { + 'chassis': { + 'led-0': { + 'readonly': True, + 'states': [ + indicator_states.OFF, + indicator_states.ON + ] + } + }, + 'system': { + 'led': { + 'readonly': False, + 'states': [ + indicator_states.BLINKING, + indicator_states.OFF, + indicator_states.ON + ] + } + } + } + self.assertEqual(expected, indicators) + + @mgr_utils.mock_record_keepalive class NmiTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): diff --git a/ironic/tests/unit/conductor/test_rpcapi.py b/ironic/tests/unit/conductor/test_rpcapi.py index ddbc8bd1bb..a4d3273982 100644 --- a/ironic/tests/unit/conductor/test_rpcapi.py +++ b/ironic/tests/unit/conductor/test_rpcapi.py @@ -26,7 +26,9 @@ import oslo_messaging as messaging from oslo_messaging import _utils as messaging_utils from ironic.common import boot_devices +from ironic.common import components from ironic.common import exception +from ironic.common import indicator_states from ironic.common import release_mappings from ironic.common import states from ironic.conductor import manager as conductor_manager @@ -362,6 +364,29 @@ class RPCAPITestCase(db_base.DbTestCase): version='1.17', node_id=self.fake_node['uuid']) + def test_set_indicator_state(self): + self._test_rpcapi('set_indicator_state', + 'call', + version='1.50', + node_id=self.fake_node['uuid'], + component=components.CHASSIS, + indicator='led', + state=indicator_states.ON) + + def test_get_indicator_state(self): + self._test_rpcapi('get_indicator_state', + 'call', + version='1.50', + node_id=self.fake_node['uuid'], + component=components.CHASSIS, + indicator='led') + + def test_get_supported_indicators(self): + self._test_rpcapi('get_supported_indicators', + 'call', + version='1.50', + node_id=self.fake_node['uuid']) + def test_get_node_vendor_passthru_methods(self): self._test_rpcapi('get_node_vendor_passthru_methods', 'call', diff --git a/releasenotes/notes/add-indicator-api-8c816b3828e6b43b.yaml b/releasenotes/notes/add-indicator-api-8c816b3828e6b43b.yaml new file mode 100644 index 0000000000..f57deb72cd --- /dev/null +++ b/releasenotes/notes/add-indicator-api-8c816b3828e6b43b.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Adds REST API endpoints for indicator management. Three new endpoints, for + listing, reading and setting the indicators, reside under the + ``/v1/nodes//management/indicators`` location.