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 2e3867eb76..77e28d541f 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -540,6 +540,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)