From 8b09bdf22f47bc31cf1f3fef8cd0cc60fe9d5078 Mon Sep 17 00:00:00 2001 From: whaom Date: Mon, 14 Jul 2014 02:01:06 +0800 Subject: [PATCH] Implements send-data-to-ceilometer This patch will define the sensor data collection interface and implement a driver based on IPMI to collect sensor data and send them to Ceilometer. The change covered by this patch: 1. Creates a new ironic.driver.base.ManagementInterface common method *get_sensors_data* for gathering hardware sensor data. 2. Implements the interface into 'ipmitool' driver to gather data via IPMI command call. 3. Adds periodic task to conductor which emits notification to Ceilometer by an interval. Spec: https://review.openstack.org/102435 Change-Id: I2c072321a662db0162c938b7d4f3b59ba07d4e08 Implements: blueprint send-data-to-ceilometer --- doc/source/deploy/install-guide.rst | 19 +++ etc/ironic/ironic.conf.sample | 13 ++ ironic/common/exception.py | 10 ++ ironic/conductor/manager.py | 83 ++++++++++ ironic/drivers/base.py | 37 +++++ ironic/drivers/modules/fake.py | 3 + ironic/drivers/modules/ipminative.py | 10 ++ ironic/drivers/modules/ipmitool.py | 101 ++++++++++++ ironic/drivers/modules/seamicro.py | 9 ++ ironic/drivers/modules/ssh.py | 10 ++ ironic/tests/conductor/test_manager.py | 70 +++++++++ ironic/tests/drivers/test_ipmitool.py | 205 +++++++++++++++++++++++++ 12 files changed, 570 insertions(+) diff --git a/doc/source/deploy/install-guide.rst b/doc/source/deploy/install-guide.rst index 7f5724e3d4..a1ec3df8e6 100644 --- a/doc/source/deploy/install-guide.rst +++ b/doc/source/deploy/install-guide.rst @@ -351,3 +351,22 @@ http://ipmitool.sourceforge.net/ Note that certain distros, notably Mac OS X and SLES, install ``openipmi`` instead of ``ipmitool`` by default. THIS DRIVER IS NOT COMPATIBLE WITH ``openipmi`` AS IT RELIES ON ERROR HANDLING OPTIONS NOT PROVIDED BY THIS TOOL. + +Ironic supports sending IPMI sensor data to Ceilometer with pxe_ipmitool +driver. By default, support for sending IPMI sensor data to Ceilometer is +disabled. If you want to enable it set the following options in the +``conductor`` section of ``ironic.conf``: + +* notification_driver=messaging +* send_sensor_data=true + +If you want to customize the sensor types which will be sent to Ceilometer, +change the ``send_sensor_data_types`` option. For example, the below settings +will send Temperature,Fan,Voltage these three sensor types data to Ceilometer: + +* send_sensor_data_types=Temperature,Fan,Voltage + +Else we use default value 'All' for all the sensor types which supported by +Ceilometer, they are: + +* Temperature,Fan,Voltage,Current diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 6c9e101e17..47eedf83fb 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -553,6 +553,19 @@ # Seconds to sleep between node lock attempts. (integer value) #node_locked_retry_interval=1 +# Enable sending sensor data message via the notification bus +# (boolean value) +#send_sensor_data=false + +# Seconds between conductor sending sensor data message to +# ceilometer via the notification bus. (integer value) +#send_sensor_data_interval=600 + +# List of comma separated metric types which need to be sent +# to Ceilometer. The default value, "ALL", is a special value +# meaning send all the sensor data. (list value) +#send_sensor_data_types=ALL + [console] diff --git a/ironic/common/exception.py b/ironic/common/exception.py index a865e4ad88..7c7dbdd09b 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -398,3 +398,13 @@ class PasswordFileFailedToCreate(IronicException): class IloOperationError(IronicException): message = _("%(operation)s failed, error: %(error)s") + + +class FailedToGetSensorData(IronicException): + message = _("Failed to get sensor data for node %(node)s. " + "Error: %(error)s") + + +class FailedToParseSensorData(IronicException): + message = _("Failed to parse sensor data for node %(node)s. " + "Error: %(error)s") diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 6a7d558ee0..0a24ac6222 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -42,6 +42,7 @@ a change, etc. """ import collections +import datetime import threading import eventlet @@ -55,7 +56,9 @@ from ironic.common import exception from ironic.common import hash_ring as hash from ironic.common import i18n from ironic.common import neutron +from ironic.common import rpc from ironic.common import states +from ironic.common import utils as ironic_utils from ironic.conductor import task_manager from ironic.conductor import utils from ironic.db import api as dbapi @@ -123,6 +126,20 @@ conductor_opts = [ cfg.IntOpt('node_locked_retry_interval', default=1, help='Seconds to sleep between node lock attempts.'), + cfg.BoolOpt('send_sensor_data', + default=False, + help='Enable sending sensor data message via the ' + 'notification bus'), + cfg.IntOpt('send_sensor_data_interval', + default=600, + help='Seconds between conductor sending sensor data message' + ' to ceilometer via the notification bus.'), + cfg.ListOpt('send_sensor_data_types', + default=['ALL'], + help='List of comma separated metric types which need to be' + ' sent to Ceilometer. The default value, "ALL", is a ' + 'special value meaning send all the sensor data.' + ), ] CONF = cfg.CONF @@ -143,6 +160,7 @@ class ConductorManager(periodic_task.PeriodicTasks): self.host = host self.topic = topic self.power_state_sync_count = collections.defaultdict(int) + self.notifier = rpc.get_notifier() def _get_driver(self, driver_name): """Get the driver. @@ -1048,3 +1066,68 @@ class ConductorManager(periodic_task.PeriodicTasks): driver_name) driver = self._get_driver(driver_name) return driver.get_properties() + + @periodic_task.periodic_task( + spacing=CONF.conductor.send_sensor_data_interval) + def _send_sensor_data(self, context): + # do nothing if send_sensor_data option is False + if not CONF.conductor.send_sensor_data: + return + + filters = {'associated': True} + columns = ['uuid', 'driver', 'instance_uuid'] + node_list = self.dbapi.get_nodeinfo_list(columns=columns, + filters=filters) + + for (node_uuid, driver, instance_uuid) in node_list: + # only handle the nodes mapped to this conductor + if not self._mapped_to_this_conductor(node_uuid, driver): + continue + + # populate the message which will be sent to ceilometer + message = {'message_id': ironic_utils.generate_uuid(), + 'instance_uuid': instance_uuid, + 'node_uuid': node_uuid, + 'timestamp': datetime.datetime.utcnow(), + 'event_type': 'hardware.ipmi.metrics.update'} + + try: + with task_manager.acquire(context, node_uuid, shared=True) \ + as task: + sensors_data = task.driver.management.get_sensors_data( + task) + except NotImplementedError: + LOG.warn(_LW('get_sensors_data is not implemented for driver' + ' %(driver)s, node_uuid is %(node)s'), + {'node': node_uuid, 'driver': driver}) + except exception.FailedToParseSensorData as fps: + LOG.warn(_LW("During get_sensors_data, could not parse " + "sensor data for node %(node)s. Error: %(err)s."), + {'node': node_uuid, 'err': str(fps)}) + except exception.FailedToGetSensorData as fgs: + LOG.warn(_LW("During get_sensors_data, could not get " + "sensor data for node %(node)s. Error: %(err)s."), + {'node': node_uuid, 'err': str(fgs)}) + except exception.NodeNotFound: + LOG.warn(_LW("During send_sensor_data, node %(node)s was not " + "found and presumed deleted by another process."), + {'node': node_uuid}) + except Exception as e: + LOG.warn(_LW("Failed to get sensor data for node %(node)s. " + "Error: %(error)s"), {'node': node_uuid, 'error': str(e)}) + else: + message['payload'] = self._filter_out_unsupported_types( + sensors_data) + if message['payload']: + self.notifier.info(context, "hardware.ipmi.metrics", + message) + + def _filter_out_unsupported_types(self, sensors_data): + # support the CONF.send_sensor_data_types sensor types only + allowed = set(x.lower() for x in CONF.conductor.send_sensor_data_types) + + if 'all' in allowed: + return sensors_data + + return dict((sensor_type, sensor_value) for (sensor_type, sensor_value) + in sensors_data.items() if sensor_type.lower() in allowed) diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index 4ba16a804d..0a6446a26a 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -463,3 +463,40 @@ class ManagementInterface(object): future boots or not, None if it is unknown. """ + + @abc.abstractmethod + def get_sensors_data(self, task): + """Get sensors data method. + + :param task: a TaskManager instance. + :raises: FailedToGetSensorData when getting the sensor data fails. + :raises: FailedToParseSensorData when parsing sensor data fails. + :returns: returns a consistent format dict of sensor data grouped by + sensor type, which can be processed by Ceilometer. + eg, { + 'Sensor Type 1': { + 'Sensor ID 1': { + 'Sensor Reading': 'current value', + 'key1': 'value1', + 'key2': 'value2' + }, + 'Sensor ID 2': { + 'Sensor Reading': 'current value', + 'key1': 'value1', + 'key2': 'value2' + } + }, + 'Sensor Type 2': { + 'Sensor ID 3': { + 'Sensor Reading': 'current value', + 'key1': 'value1', + 'key2': 'value2' + }, + 'Sensor ID 4': { + 'Sensor Reading': 'current value', + 'key1': 'value1', + 'key2': 'value2' + } + } + } + """ diff --git a/ironic/drivers/modules/fake.py b/ironic/drivers/modules/fake.py index 1ae10f7f67..e2ea7aceb5 100644 --- a/ironic/drivers/modules/fake.py +++ b/ironic/drivers/modules/fake.py @@ -182,3 +182,6 @@ class FakeManagement(base.ManagementInterface): def get_boot_device(self, task): return {'boot_device': boot_devices.PXE, 'persistent': False} + + def get_sensors_data(self, task): + return {} diff --git a/ironic/drivers/modules/ipminative.py b/ironic/drivers/modules/ipminative.py index f2a05d7b86..6b6bcada95 100644 --- a/ironic/drivers/modules/ipminative.py +++ b/ironic/drivers/modules/ipminative.py @@ -388,3 +388,13 @@ class NativeIPMIManagement(base.ManagementInterface): _BOOT_DEVICES_MAP.items() if hdev == bootdev), None) return response + + def get_sensors_data(self, task): + """Get sensors data. + + Not implemented by this driver. + + :param task: a TaskManager instance. + + """ + raise NotImplementedError() diff --git a/ironic/drivers/modules/ipmitool.py b/ironic/drivers/modules/ipmitool.py index 9060eb68a5..0c32ca8a00 100644 --- a/ironic/drivers/modules/ipmitool.py +++ b/ironic/drivers/modules/ipmitool.py @@ -374,6 +374,76 @@ def _power_status(driver_info): return states.ERROR +def _process_sensor(sensor_data): + sensor_data_fields = sensor_data.split('\n') + sensor_data_dict = {} + for field in sensor_data_fields: + if not field: + continue + kv_value = field.split(':') + if len(kv_value) != 2: + continue + sensor_data_dict[kv_value[0].strip()] = kv_value[1].strip() + + return sensor_data_dict + + +def _get_sensor_type(node, sensor_data_dict): + # Have only three sensor type name IDs: 'Sensor Type (Analog)' + # 'Sensor Type (Discrete)' and 'Sensor Type (Threshold)' + + for key in ('Sensor Type (Analog)', 'Sensor Type (Discrete)', + 'Sensor Type (Threshold)'): + try: + return sensor_data_dict[key].split(' ', 1)[0] + except KeyError: + continue + + raise exception.FailedToParseSensorData( + node=node.uuid, + error=(_("parse ipmi sensor data failed, unknown sensor type" + " data: %(sensors_data)s"), {'sensors_data': sensor_data_dict})) + + +def _parse_ipmi_sensors_data(node, sensors_data): + """Parse the IPMI sensors data and format to the dict grouping by type. + + We run 'ipmitool' command with 'sdr -v' options, which can return sensor + details in human-readable format, we need to format them to JSON string + dict-based data for Ceilometer Collector which can be sent it as payload + out via notification bus and consumed by Ceilometer Collector. + + :param sensors_data: the sensor data returned by ipmitool command. + :returns: the sensor data with JSON format, grouped by sensor type. + :raises: FailedToParseSensorData when error encountered during parsing. + + """ + sensors_data_dict = {} + if not sensors_data: + return sensors_data_dict + + sensors_data_array = sensors_data.split('\n\n') + for sensor_data in sensors_data_array: + sensor_data_dict = _process_sensor(sensor_data) + if not sensor_data_dict: + continue + + sensor_type = _get_sensor_type(node, sensor_data_dict) + + # ignore the sensors which has no current 'Sensor Reading' data + if 'Sensor Reading' in sensor_data_dict: + sensors_data_dict.setdefault(sensor_type, + {})[sensor_data_dict['Sensor ID']] = sensor_data_dict + + # get nothing, no valid sensor data + if not sensors_data_dict: + raise exception.FailedToParseSensorData( + node=node.uuid, + error=(_("parse ipmi sensor data failed, get nothing with input" + " data: %(sensors_data)s") % {'sensors_data': sensors_data})) + return sensors_data_dict + + class IPMIPower(base.PowerInterface): def __init__(self): @@ -464,6 +534,15 @@ class IPMIManagement(base.ManagementInterface): def get_properties(self): return COMMON_PROPERTIES + def __init__(self): + try: + check_timing_support() + except OSError: + raise exception.DriverLoadError( + driver=self.__class__.__name__, + reason="Unable to locate usable ipmitool command in " + "the system path when checking ipmitool version") + def validate(self, task): """Check that 'driver_info' contains IPMI credentials. @@ -569,6 +648,28 @@ class IPMIManagement(base.ManagementInterface): response['persistent'] = 'Options apply to all future boots' in out return response + def get_sensors_data(self, task): + """Get sensors data. + + :param task: a TaskManager instance. + :raises: FailedToGetSensorData when getting the sensor data fails. + :raises: FailedToParseSensorData when parsing sensor data fails. + :raises: InvalidParameterValue if required ipmi parameters are missing + :returns: returns a dict of sensor data group by sensor type. + + """ + driver_info = _parse_driver_info(task.node) + # with '-v' option, we can get the entire sensor data including the + # extended sensor informations + cmd = "sdr -v" + try: + out, err = _exec_ipmitool(driver_info, cmd) + except processutils.ProcessExecutionError as pee: + raise exception.FailedToGetSensorData(node=task.node.uuid, + error=str(pee)) + + return _parse_ipmi_sensors_data(task.node, out) + class VendorPassthru(base.VendorInterface): diff --git a/ironic/drivers/modules/seamicro.py b/ironic/drivers/modules/seamicro.py index 265131aa67..0ab2c549f2 100644 --- a/ironic/drivers/modules/seamicro.py +++ b/ironic/drivers/modules/seamicro.py @@ -559,3 +559,12 @@ class Management(base.ManagementInterface): # doesn't expose a method to get the boot device, update it once # it's implemented. return {'boot_device': None, 'persistent': None} + + def get_sensors_data(self, task): + """Get sensors data method. + + Not implemented by this driver. + :param task: a TaskManager instance. + + """ + raise NotImplementedError() diff --git a/ironic/drivers/modules/ssh.py b/ironic/drivers/modules/ssh.py index 535d28f2e9..429f6d0488 100644 --- a/ironic/drivers/modules/ssh.py +++ b/ironic/drivers/modules/ssh.py @@ -633,3 +633,13 @@ class SSHManagement(base.ManagementInterface): "operation"), {'node': node.uuid, 'vtype': driver_info['virt_type']}) return response + + def get_sensors_data(self, task): + """Get sensors data. + + Not implemented by this driver. + + :param task: a TaskManager instance. + + """ + raise NotImplementedError() diff --git a/ironic/tests/conductor/test_manager.py b/ironic/tests/conductor/test_manager.py index c0b7053ec6..8ba75a3448 100644 --- a/ironic/tests/conductor/test_manager.py +++ b/ironic/tests/conductor/test_manager.py @@ -1218,6 +1218,76 @@ class UpdatePortTestCase(_ServiceSetUpMixin, tests_db_base.DbTestCase): self.assertEqual(new_address, res.address) self.assertFalse(mac_update_mock.called) + def test__filter_out_unsupported_types_all(self): + self._start_service() + CONF.set_override('send_sensor_data_types', ['All'], group='conductor') + fake_sensors_data = {"t1": {'f1': 'v1'}, "t2": {'f1': 'v1'}} + actual_result = self.service._filter_out_unsupported_types( + fake_sensors_data) + expected_result = {"t1": {'f1': 'v1'}, "t2": {'f1': 'v1'}} + self.assertEqual(expected_result, actual_result) + + def test__filter_out_unsupported_types_part(self): + self._start_service() + CONF.set_override('send_sensor_data_types', ['t1'], group='conductor') + fake_sensors_data = {"t1": {'f1': 'v1'}, "t2": {'f1': 'v1'}} + actual_result = self.service._filter_out_unsupported_types( + fake_sensors_data) + expected_result = {"t1": {'f1': 'v1'}} + self.assertEqual(expected_result, actual_result) + + def test__filter_out_unsupported_types_non(self): + self._start_service() + CONF.set_override('send_sensor_data_types', ['t3'], group='conductor') + fake_sensors_data = {"t1": {'f1': 'v1'}, "t2": {'f1': 'v1'}} + actual_result = self.service._filter_out_unsupported_types( + fake_sensors_data) + expected_result = {} + self.assertEqual(expected_result, actual_result) + + @mock.patch.object(manager.ConductorManager, '_mapped_to_this_conductor') + @mock.patch.object(dbapi.IMPL, 'get_nodeinfo_list') + @mock.patch.object(task_manager, 'acquire') + def test___send_sensor_data(self, acquire_mock, get_nodeinfo_list_mock, + _mapped_to_this_conductor_mock): + node = obj_utils.create_test_node(self.context, + driver='fake') + self._start_service() + CONF.set_override('send_sensor_data', True, group='conductor') + acquire_mock.return_value.__enter__.return_value.driver = self.driver + with mock.patch.object(self.driver.management, + 'get_sensors_data') as get_sensors_data_mock: + get_sensors_data_mock.return_value = 'fake-sensor-data' + _mapped_to_this_conductor_mock.return_value = True + get_nodeinfo_list_mock.return_value = [(node.uuid, node.driver, + node.instance_uuid)] + self.service._send_sensor_data(self.context) + self.assertTrue(get_nodeinfo_list_mock.called) + self.assertTrue(_mapped_to_this_conductor_mock.called) + self.assertTrue(acquire_mock.called) + self.assertTrue(get_sensors_data_mock.called) + + @mock.patch.object(manager.ConductorManager, '_mapped_to_this_conductor') + @mock.patch.object(dbapi.IMPL, 'get_nodeinfo_list') + @mock.patch.object(task_manager, 'acquire') + def test___send_sensor_data_disabled(self, acquire_mock, + get_nodeinfo_list_mock, _mapped_to_this_conductor_mock): + node = obj_utils.create_test_node(self.context, + driver='fake') + self._start_service() + acquire_mock.return_value.__enter__.return_value.driver = self.driver + with mock.patch.object(self.driver.management, + 'get_sensors_data') as get_sensors_data_mock: + get_sensors_data_mock.return_value = 'fake-sensor-data' + _mapped_to_this_conductor_mock.return_value = True + get_nodeinfo_list_mock.return_value = [(node.uuid, node.driver, + node.instance_uuid)] + self.service._send_sensor_data(self.context) + self.assertFalse(get_nodeinfo_list_mock.called) + self.assertFalse(_mapped_to_this_conductor_mock.called) + self.assertFalse(acquire_mock.called) + self.assertFalse(get_sensors_data_mock.called) + class ManagerSpawnWorkerTestCase(tests_base.TestCase): def setUp(self): diff --git a/ironic/tests/drivers/test_ipmitool.py b/ironic/tests/drivers/test_ipmitool.py index 55751a987a..9ac46ac338 100644 --- a/ironic/tests/drivers/test_ipmitool.py +++ b/ironic/tests/drivers/test_ipmitool.py @@ -917,3 +917,208 @@ class IPMIToolDriverTestCase(db_base.DbTestCase): with task_manager.acquire(self.context, node.uuid) as task: self.assertRaises(exception.InvalidParameterValue, task.driver.management.validate, task) + + def test__parse_ipmi_sensor_data_ok(self): + fake_sensors_data = """ + Sensor ID : Temp (0x1) + Entity ID : 3.1 (Processor) + Sensor Type (Analog) : Temperature + Sensor Reading : -58 (+/- 1) degrees C + Status : ok + Nominal Reading : 50.000 + Normal Minimum : 11.000 + Normal Maximum : 69.000 + Upper critical : 90.000 + Upper non-critical : 85.000 + Positive Hysteresis : 1.000 + Negative Hysteresis : 1.000 + + Sensor ID : Temp (0x2) + Entity ID : 3.2 (Processor) + Sensor Type (Analog) : Temperature + Sensor Reading : 50 (+/- 1) degrees C + Status : ok + Nominal Reading : 50.000 + Normal Minimum : 11.000 + Normal Maximum : 69.000 + Upper critical : 90.000 + Upper non-critical : 85.000 + Positive Hysteresis : 1.000 + Negative Hysteresis : 1.000 + + Sensor ID : FAN MOD 1A RPM (0x30) + Entity ID : 7.1 (System Board) + Sensor Type (Analog) : Fan + Sensor Reading : 8400 (+/- 75) RPM + Status : ok + Nominal Reading : 5325.000 + Normal Minimum : 10425.000 + Normal Maximum : 14775.000 + Lower critical : 4275.000 + Positive Hysteresis : 375.000 + Negative Hysteresis : 375.000 + + Sensor ID : FAN MOD 1B RPM (0x31) + Entity ID : 7.1 (System Board) + Sensor Type (Analog) : Fan + Sensor Reading : 8550 (+/- 75) RPM + Status : ok + Nominal Reading : 7800.000 + Normal Minimum : 10425.000 + Normal Maximum : 14775.000 + Lower critical : 4275.000 + Positive Hysteresis : 375.000 + Negative Hysteresis : 375.000 + """ + expected_return = { + 'Fan': { + 'FAN MOD 1A RPM (0x30)': { + 'Status': 'ok', + 'Sensor Reading': '8400 (+/- 75) RPM', + 'Entity ID': '7.1 (System Board)', + 'Normal Minimum': '10425.000', + 'Positive Hysteresis': '375.000', + 'Normal Maximum': '14775.000', + 'Sensor Type (Analog)': 'Fan', + 'Lower critical': '4275.000', + 'Negative Hysteresis': '375.000', + 'Sensor ID': 'FAN MOD 1A RPM (0x30)', + 'Nominal Reading': '5325.000' + }, + 'FAN MOD 1B RPM (0x31)': { + 'Status': 'ok', + 'Sensor Reading': '8550 (+/- 75) RPM', + 'Entity ID': '7.1 (System Board)', + 'Normal Minimum': '10425.000', + 'Positive Hysteresis': '375.000', + 'Normal Maximum': '14775.000', + 'Sensor Type (Analog)': 'Fan', + 'Lower critical': '4275.000', + 'Negative Hysteresis': '375.000', + 'Sensor ID': 'FAN MOD 1B RPM (0x31)', + 'Nominal Reading': '7800.000' + } + }, + 'Temperature': { + 'Temp (0x1)': { + 'Status': 'ok', + 'Sensor Reading': '-58 (+/- 1) degrees C', + 'Entity ID': '3.1 (Processor)', + 'Normal Minimum': '11.000', + 'Positive Hysteresis': '1.000', + 'Upper non-critical': '85.000', + 'Normal Maximum': '69.000', + 'Sensor Type (Analog)': 'Temperature', + 'Negative Hysteresis': '1.000', + 'Upper critical': '90.000', + 'Sensor ID': 'Temp (0x1)', + 'Nominal Reading': '50.000' + }, + 'Temp (0x2)': { + 'Status': 'ok', + 'Sensor Reading': '50 (+/- 1) degrees C', + 'Entity ID': '3.2 (Processor)', + 'Normal Minimum': '11.000', + 'Positive Hysteresis': '1.000', + 'Upper non-critical': '85.000', + 'Normal Maximum': '69.000', + 'Sensor Type (Analog)': 'Temperature', + 'Negative Hysteresis': '1.000', + 'Upper critical': '90.000', + 'Sensor ID': 'Temp (0x2)', + 'Nominal Reading': '50.000' + } + } + } + ret = ipmi._parse_ipmi_sensors_data(self.node, fake_sensors_data) + + self.assertEqual(expected_return, ret) + + def test__parse_ipmi_sensor_data_missing_sensor_reading(self): + fake_sensors_data = """ + Sensor ID : Temp (0x1) + Entity ID : 3.1 (Processor) + Sensor Type (Analog) : Temperature + Status : ok + Nominal Reading : 50.000 + Normal Minimum : 11.000 + Normal Maximum : 69.000 + Upper critical : 90.000 + Upper non-critical : 85.000 + Positive Hysteresis : 1.000 + Negative Hysteresis : 1.000 + + Sensor ID : Temp (0x2) + Entity ID : 3.2 (Processor) + Sensor Type (Analog) : Temperature + Sensor Reading : 50 (+/- 1) degrees C + Status : ok + Nominal Reading : 50.000 + Normal Minimum : 11.000 + Normal Maximum : 69.000 + Upper critical : 90.000 + Upper non-critical : 85.000 + Positive Hysteresis : 1.000 + Negative Hysteresis : 1.000 + + Sensor ID : FAN MOD 1A RPM (0x30) + Entity ID : 7.1 (System Board) + Sensor Type (Analog) : Fan + Sensor Reading : 8400 (+/- 75) RPM + Status : ok + Nominal Reading : 5325.000 + Normal Minimum : 10425.000 + Normal Maximum : 14775.000 + Lower critical : 4275.000 + Positive Hysteresis : 375.000 + Negative Hysteresis : 375.000 + """ + expected_return = { + 'Fan': { + 'FAN MOD 1A RPM (0x30)': { + 'Status': 'ok', + 'Sensor Reading': '8400 (+/- 75) RPM', + 'Entity ID': '7.1 (System Board)', + 'Normal Minimum': '10425.000', + 'Positive Hysteresis': '375.000', + 'Normal Maximum': '14775.000', + 'Sensor Type (Analog)': 'Fan', + 'Lower critical': '4275.000', + 'Negative Hysteresis': '375.000', + 'Sensor ID': 'FAN MOD 1A RPM (0x30)', + 'Nominal Reading': '5325.000' + } + }, + 'Temperature': { + 'Temp (0x2)': { + 'Status': 'ok', + 'Sensor Reading': '50 (+/- 1) degrees C', + 'Entity ID': '3.2 (Processor)', + 'Normal Minimum': '11.000', + 'Positive Hysteresis': '1.000', + 'Upper non-critical': '85.000', + 'Normal Maximum': '69.000', + 'Sensor Type (Analog)': 'Temperature', + 'Negative Hysteresis': '1.000', + 'Upper critical': '90.000', + 'Sensor ID': 'Temp (0x2)', + 'Nominal Reading': '50.000' + } + } + } + ret = ipmi._parse_ipmi_sensors_data(self.node, fake_sensors_data) + + self.assertEqual(expected_return, ret) + + def test__parse_ipmi_sensor_data_failed(self): + fake_sensors_data = "abcdef" + self.assertRaises(exception.FailedToParseSensorData, + ipmi._parse_ipmi_sensors_data, + self.node, + fake_sensors_data) + + fake_sensors_data = "abc:def:ghi" + self.assertRaises(exception.FailedToParseSensorData, + ipmi._parse_ipmi_sensors_data, + self.node, + fake_sensors_data)