From 17afa5ce78e93d904bbd078663535b6add44ab48 Mon Sep 17 00:00:00 2001
From: Guillaume <guillaume.thouvenin@vates.tech>
Date: Thu, 5 Sep 2024 16:59:43 +0200
Subject: [PATCH] Add a plugin to get information from ipmitool

- is_ipmi_device_available: check that your system supports IPMI by
  running `ipmitool power state`
    - returns true if IPMI is supported, false otherwise
- get_all_sensors: run `ipmitool sdr list`
    - returns a JSON string with all sensors or raise an error
- get_sensor: run `ipmitool sdr get <sensor>`
    - returns details about sensors passed as parameter or raise an
      error
- get_ipmi_lan: run `ipmitool lan print`
    - returns network info of the IPMI server as a JSON or
      raise an error

Signed-off-by: Guillaume <guillaume.thouvenin@vates.tech>
---
 README.md                              |  85 +++++++
 SOURCES/etc/xapi.d/plugins/ipmitool.py | 136 +++++++++++
 tests/test_ipmitool.py                 | 324 +++++++++++++++++++++++++
 3 files changed, 545 insertions(+)
 create mode 100755 SOURCES/etc/xapi.d/plugins/ipmitool.py
 create mode 100644 tests/test_ipmitool.py

diff --git a/README.md b/README.md
index c1aba22..b20e760 100644
--- a/README.md
+++ b/README.md
@@ -201,6 +201,91 @@ $ xe host-call-plugin host-uuid=<uuid> plugin=hyperthreading.py fn=get_hyperthre
 true
 ```
 
+## Ipmitool
+
+A xapi plugin that uses `ipmitool` to get information about sensors and the IPMI server. Before
+running the commands you need to ensure that your system have support for IPMI.
+
+### `is_ipmi_device_available`
+
+Returns `true` if IPMI device is found and `ipmitool` can be used. If it could not open device at `/dev/ipmi*`
+it returns `false`. In that case you need to ensure the IPMI module is loaded and that your system
+supports IPMI. Others unexpected errors raise a XenAPIPlugin error.
+
+```
+$ xe host-call-plugin host-uuid=<uuid> plugin=ipmitool.py fn=is_ipmi_device_available
+false
+```
+
+If `true` is returned you should be able to run `get_all_sensors`, `get_sensor`
+or `get_ipmi_lan` without raising a XenAPIPlugin error.
+
+### `get_all_sensors`
+
+Returns a JSON containing all sensor data repository entries and readings or raise a XenAPIPlugin error.
+```
+$ xe host-call-plugin host-uuid=<uuid> plugin=ipmitool.py fn=get_all_sensors
+[
+ {"name": "Fan1A", "value": "10920 RPM", "event": "ok"},
+ {"name": "Fan2A", "value": "10800 RPM", "event": "ok"},
+ {"name": "Inlet Temp", "value": "23 degrees C", "event": "ok"},
+ {"name": "Exhaust Temp", "value": "28 degrees C", "event": "ok"},
+ {"name": "Temp", "value": "38 degrees C", "event": "ok"}
+ {"name": "PFault Fail Safe", "value": "Not Readable", "event": "ns"}
+ ...
+]
+```
+
+### `get_sensor`
+
+Returns a JSON containing detailed information about the sensors passed as paramaters
+or raise an XenAPIPlugin error. The names of the sensors can be found by running `get_all_sensors`
+function. If a wrong sensor name is passed an error is logged in `/var/log/ipmitool-xapi-plugin-plugin.log`
+and the sensor is skipped.
+
+```
+$ xe host-call-plugin host-uuid=<uuid> plugin=ipmitool.py fn=get_sensor args:sensors="Fan7B,PFault Fail Safe"
+[
+  {
+    "name": "Fan7B",
+    "info": [{"name": "Sensor ID", "value": "Fan7B (0x3d)"}, {"name": "Entity ID", "value": "7.1 (System Board)"}, {"name": "Sensor Type (Threshold)", "value": "Fan (0x04)"}, {"name": "Sensor Reading", "value": "10320 (+/- 120) RPM"}, {"name": "Status", "value": "ok"}, {"name": "Nominal Reading", "value": "6720.000"}, {"name": "Normal Minimum", "value": "16680.000"}, {"name": "Normal Maximum", "value": "23640.000"}, {"name": "Lower critical", "value": "720.000"}, {"name": "Lower non-critical", "value": "840.000"}, {"name": "Positive Hysteresis", "value": "120.000"}, {"name": "Negative Hysteresis", "value": "120.000"}, {"name": "Minimum sensor range", "value": "Unspecified"}, {"name": "Maximum sensor range", "value": "Unspecified"}, {"name": "Event Message Control", "value": "Per-threshold"}, {"name": "Readable Thresholds", "value": "lcr lnc"}, {"name": "Settable Thresholds", "value": ""}, {"name": "Threshold Read Mask", "value": "lcr lnc"}, {"name": "Assertion Events", "value": ""}, {"name": "Assertions Enabled", "value": "lnc- lcr-"}, {"name": "Deassertions Enabled", "value": "lnc- lcr-"}]
+  },
+  {
+    "name": "PFault Fail Safe",
+    "info": [{"name": "Sensor ID", "value": "PFault Fail Safe (0x66)"}, {"name": "Entity ID", "value": "7.1 (System Board)"}, {"name": "Sensor Type (Discrete)", "value": "Voltage (0x02)"}, {"name": "Sensor Reading", "value": "No Reading"}, {"name": "Event Message Control", "value": "Per-threshold"}, {"name": "OEM", "value": "0"}]
+  }
+]
+```
+
+### `get_ipmi_lan`
+
+Returns JSON that contains information about the configuration of the network related to the IPMI server
+or raise a XenAPIPlugin error.
+
+```
+$ xe host-call-plugin host-uuid=<uuid> plugin=ipmitool.py fn=get_ipmi_lan
+[
+  {
+    "name": "IP Address Source",
+    "value": "Static Address"
+  },
+  {
+    "name": "IP Address",
+    "value": "1.2.3.4"
+  },
+  {
+    "name": "Subnet Mask",
+    "value": "255.255.255.0"
+  },
+  {
+    "name": "MAC Address",
+    "value": "a8:ac:a2:a5:a0:ae"
+  },
+  ...
+]
+
+```
+
 ## Tests
 
 To run the plugins' unit tests you'll need to install `pytest`, `pyfakefs` and `mock`.
diff --git a/SOURCES/etc/xapi.d/plugins/ipmitool.py b/SOURCES/etc/xapi.d/plugins/ipmitool.py
new file mode 100755
index 0000000..ab887ee
--- /dev/null
+++ b/SOURCES/etc/xapi.d/plugins/ipmitool.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import json
+import sys
+import XenAPIPlugin
+
+sys.path.append(".")
+from xcpngutils import (
+    configure_logging,
+    run_command,
+    error_wrapped,
+    ProcessException,
+    raise_plugin_error,
+)
+
+
+@error_wrapped
+def _is_ipmi_available():
+    # Try to run a simple command to check if ipmitool works
+    # If the command raise an error saying that we cannot open the IPMI device it means
+    # that IPMI is not available on the system. So we return False. If we don't know the
+    # error we raise it again and it will need to be debugged...
+    try:
+        _ = run_command(["ipmitool", "chassis", "status"])
+    except ProcessException as e:
+        if "Could not open device" in e.stderr:
+            return False
+        else:
+            raise e
+
+    return True
+
+
+def is_ipmi_device_available(_session, _args):
+    return json.dumps(_is_ipmi_available())
+
+
+@error_wrapped
+def check_ipmi_availability(func):
+    def wrapper(*args, **kwargs):
+        if not _is_ipmi_available():
+            raise_plugin_error(1, "IPMI not available")
+        return func(*args, **kwargs)
+
+    return wrapper
+
+
+@check_ipmi_availability
+def sensor_data(_session, _args):
+    sensor_data = []
+    output = run_command(["ipmitool", "sdr", "list"])
+
+    for line in output["stdout"].splitlines():
+        if not line:
+            continue
+        sensor_fields = line.split("|")
+        sensor_data.append({
+            "name": sensor_fields[0].strip(),
+            "value": sensor_fields[1].strip(),
+            "event": sensor_fields[2].strip(),
+        })
+
+    return json.dumps(sensor_data)
+
+
+@check_ipmi_availability
+def sensor_info(_session, args):
+    sensors_info = []
+    sensors = args.get("sensors")
+
+    if not sensors:
+        return "{}"
+
+    for sensor in sensors.split(","):
+        sensor = sensor.strip()
+        info = []
+        output = run_command(["ipmitool", "sdr", "get", sensor])
+
+        # If there is an error while getting info about the sensor skip it
+        # and report the error.
+        if output["stderr"]:
+            _LOGGER.error("{}".format(output["stderr"].rstrip()))
+            continue
+
+        for line in output["stdout"].splitlines():
+            if ":" not in line:
+                continue
+            name, value = line.split(":", 1)
+            info.append({
+                "name": name.strip(),
+                "value": value.strip(),
+            })
+
+        sensors_info.append({
+            "name": sensor,
+            "info": info,
+        })
+
+    return json.dumps(sensors_info)
+
+
+@check_ipmi_availability
+def ipmi_lan(_session, _args):
+    lan_info = []
+    wanted = [
+        "IP Address",
+        "Subnet Mask",
+        "MAC Address",
+        "BMC ARP Control",
+        "Default Gateway IP",
+        "802.1q VLAN",
+        "RMCP+ Cipher Suites",
+    ]
+
+    output = run_command(["ipmitool", "lan", "print"])
+
+    for line in output["stdout"].splitlines():
+        if any(word in line for word in wanted):
+            name, value = line.split(":", 1)
+            lan_info.append({
+                "name": name.strip(),
+                "value": value.strip(),
+            })
+
+    return json.dumps(lan_info)
+
+
+_LOGGER = configure_logging("ipmitool-xapi-plugin")
+if __name__ == "__main__":
+    XenAPIPlugin.dispatch({
+        "is_ipmi_device_available": is_ipmi_device_available,
+        "get_all_sensors": sensor_data,
+        "get_sensor": sensor_info,
+        "get_ipmi_lan": ipmi_lan,
+    })
diff --git a/tests/test_ipmitool.py b/tests/test_ipmitool.py
new file mode 100644
index 0000000..edcbe8a
--- /dev/null
+++ b/tests/test_ipmitool.py
@@ -0,0 +1,324 @@
+import json
+import mock
+import pytest
+import XenAPIPlugin
+from xcpngutils import ProcessException
+
+from ipmitool import is_ipmi_device_available, sensor_data, sensor_info, ipmi_lan
+
+ipmitool_sdr_list = """
+SEL              | Not Readable      | ns
+Intrusion        | 0x00              | ok
+Fan1A            | 4920 RPM          | ok
+Fan2A            | 4920 RPM          | ok
+Fan3A            | 4920 RPM          | ok
+Fan4A            | 4680 RPM          | ok
+Fan5A            | 4920 RPM          | ok
+Fan6A            | 4920 RPM          | ok
+Inlet Temp       | 24 degrees C      | ok
+Exhaust Temp     | 35 degrees C      | ok
+Temp             | 45 degrees C      | ok
+Temp             | 42 degrees C      | ok
+"""
+
+ipmitool_sdr_list_expected = [
+    {"name": "SEL", "value": "Not Readable", "event": "ns"},
+    {"name": "Intrusion", "value": "0x00", "event": "ok"},
+    {"name": "Fan1A", "value": "4920 RPM", "event": "ok"},
+    {"name": "Fan2A", "value": "4920 RPM", "event": "ok"},
+    {"name": "Fan3A", "value": "4920 RPM", "event": "ok"},
+    {"name": "Fan4A", "value": "4680 RPM", "event": "ok"},
+    {"name": "Fan5A", "value": "4920 RPM", "event": "ok"},
+    {"name": "Fan6A", "value": "4920 RPM", "event": "ok"},
+    {"name": "Inlet Temp", "value": "24 degrees C", "event": "ok"},
+    {"name": "Exhaust Temp", "value": "35 degrees C", "event": "ok"},
+    {"name": "Temp", "value": "45 degrees C", "event": "ok"},
+    {"name": "Temp", "value": "42 degrees C", "event": "ok"},
+]
+
+ipmitool_sdr_fan1 = """
+Sensor ID              : Fan1A (0x30)
+ Entity ID             : 7.1 (System Board)
+ Sensor Type (Threshold)  : Fan (0x04)
+ Sensor Reading        : 4920 (+/- 120) RPM
+ Status                : ok
+ Nominal Reading       : 10080.000
+ Normal Minimum        : 16680.000
+ Normal Maximum        : 23640.000
+ Lower critical        : 720.000
+ Lower non-critical    : 840.000
+ Positive Hysteresis   : 120.000
+ Negative Hysteresis   : 120.000
+ Minimum sensor range  : Unspecified
+ Maximum sensor range  : Unspecified
+ Event Message Control : Per-threshold
+ Readable Thresholds   : lcr lnc
+ Settable Thresholds   :
+ Threshold Read Mask   : lcr lnc
+ Assertion Events      :
+ Assertions Enabled    : lnc- lcr-
+ Deassertions Enabled  : lnc- lcr-
+"""
+
+ipmitool_sdr_fan1_expected = [{
+    "name": "Fan1A",
+    "info": [
+        {"name": "Sensor ID", "value": "Fan1A (0x30)"},
+        {"name": "Entity ID", "value": "7.1 (System Board)"},
+        {"name": "Sensor Type (Threshold)", "value": "Fan (0x04)"},
+        {"name": "Sensor Reading", "value": "4920 (+/- 120) RPM"},
+        {"name": "Status", "value": "ok"},
+        {"name": "Nominal Reading", "value": "10080.000"},
+        {"name": "Normal Minimum", "value": "16680.000"},
+        {"name": "Normal Maximum", "value": "23640.000"},
+        {"name": "Lower critical", "value": "720.000"},
+        {"name": "Lower non-critical", "value": "840.000"},
+        {"name": "Positive Hysteresis", "value": "120.000"},
+        {"name": "Negative Hysteresis", "value": "120.000"},
+        {"name": "Minimum sensor range", "value": "Unspecified"},
+        {"name": "Maximum sensor range", "value": "Unspecified"},
+        {"name": "Event Message Control", "value": "Per-threshold"},
+        {"name": "Readable Thresholds", "value": "lcr lnc"},
+        {"name": "Settable Thresholds", "value": ""},
+        {"name": "Threshold Read Mask", "value": "lcr lnc"},
+        {"name": "Assertion Events", "value": ""},
+        {"name": "Assertions Enabled", "value": "lnc- lcr-"},
+        {"name": "Deassertions Enabled", "value": "lnc- lcr-"},
+    ],
+}]
+
+ipmitool_lan_print = """
+Set in Progress         : Set Complete
+Auth Type Support       : MD5
+Auth Type Enable        : Callback : MD5
+                        : User     : MD5
+                        : Operator : MD5
+                        : Admin    : MD5
+                        : OEM      :
+IP Address Source       : Static Address
+IP Address              : 172.16.1.2
+Subnet Mask             : 255.255.254.0
+MAC Address             : f8:bc:12:12:13:14
+SNMP Community String   : public
+IP Header               : TTL=0x40 Flags=0x40 Precedence=0x00 TOS=0x10
+BMC ARP Control         : ARP Responses Enabled, Gratuitous ARP Disabled
+Gratituous ARP Intrvl   : 2.0 seconds
+Default Gateway IP      : 172.16.210.1
+Default Gateway MAC     : 00:00:00:00:00:00
+Backup Gateway IP       : 0.0.0.0
+Backup Gateway MAC      : 00:00:00:00:00:00
+802.1q VLAN ID          : Disabled
+802.1q VLAN Priority    : 0
+RMCP+ Cipher Suites     : 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14
+Cipher Suite Priv Max   : Xaaaaaaaaaaaaaa
+                        :     X=Cipher Suite Unused
+                        :     c=CALLBACK
+                        :     u=USER
+                        :     o=OPERATOR
+                        :     a=ADMIN
+                        :     O=OEM
+Bad Password Threshold  : Not Available
+"""
+
+ipmitool_lan_print_expected = [
+    {"name": "IP Address Source", "value": "Static Address"},
+    {"name": "IP Address", "value": "172.16.1.2"},
+    {"name": "Subnet Mask", "value": "255.255.254.0"},
+    {"name": "MAC Address", "value": "f8:bc:12:12:13:14"},
+    {
+        "name": "BMC ARP Control",
+        "value": "ARP Responses Enabled, Gratuitous ARP Disabled",
+    },
+    {"name": "Default Gateway IP", "value": "172.16.210.1"},
+    {"name": "802.1q VLAN ID", "value": "Disabled"},
+    {"name": "802.1q VLAN Priority", "value": "0"},
+    {"name": "RMCP+ Cipher Suites", "value": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14"},
+]
+
+SENSOR_DATA_CMD = ["ipmitool", "sdr", "list"]
+SENSOR_INFO_CMD = ["ipmitool", "sdr", "get", "Fan1A"]
+IPMI_LAN_CMD = ["ipmitool", "lan", "print"]
+
+IPMITOOL_NORMAL_ERROR = (
+    "Could not open device at /dev/ipmi0 or /dev/ipmi/0 or /dev/ipmidev/0: "
+    "No such file or directory"
+)
+IPMITOOL_ABNORMAL_ERROR = "Unexpected Error!!!"
+
+NORMAL_ERROR_MSG = "IPMI not available"
+SENSOR_DATA_ABNORMAL_ERROR_MSG = "Command '{}' failed with code: 1".format(
+    SENSOR_DATA_CMD
+)
+SENSOR_INFO_ABNORMAL_ERROR_MSG = "Command '{}' failed with code: 1".format(
+    SENSOR_INFO_CMD
+)
+IPMI_LAN_ABNORMAL_ERROR_MSG = "Command '{}' failed with code: 1".format(IPMI_LAN_CMD)
+
+
+@mock.patch("ipmitool.run_command", autospec=True)
+class TestIpmitool:
+    #####################################################
+    # Testing is_ipmi_device_available
+    #
+    def test_ipmi_device_available(self, run_command):
+        # If the command returns then IPMI is available
+        run_command.return_value = {"stdout": ""}
+
+        output = is_ipmi_device_available(None, None)
+        assert output == json.dumps(True)
+
+    def test_ipmi_device_not_available(self, run_command):
+        # If the command failed with a known error the IPMI is not available
+        run_command.side_effect = ProcessException(
+            1,
+            "",
+            "",
+            IPMITOOL_NORMAL_ERROR,
+        )
+
+        output = is_ipmi_device_available(None, None)
+        assert output == json.dumps(False)
+
+    def test_ipmi_device_not_available_failed(self, run_command):
+        # If the command failed with an unknown error a XenAPIPlugin Failure should be
+        # raised
+        run_command.side_effect = ProcessException(
+            1,
+            "",
+            "",
+            IPMITOOL_ABNORMAL_ERROR,
+        )
+
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = is_ipmi_device_available(None, None)
+
+        assert e.value.params[0] == "1"
+
+    #####################################################
+    # Testing sensor_data
+    #
+    def test_sensor_data_success(self, run_command):
+        run_command.return_value = {"stdout": ipmitool_sdr_list}
+
+        output = sensor_data(None, None)
+        assert output == json.dumps(ipmitool_sdr_list_expected)
+
+    def test_sensor_data_normal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            SENSOR_DATA_CMD,
+            "",
+            IPMITOOL_NORMAL_ERROR,
+        )
+
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = sensor_data(None, None)
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == NORMAL_ERROR_MSG
+
+    def test_sensor_data_abnormal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            SENSOR_DATA_CMD,
+            "",
+            IPMITOOL_ABNORMAL_ERROR,
+        )
+
+        # We are expecting the exception to be raised again
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = sensor_data(None, None)
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == SENSOR_DATA_ABNORMAL_ERROR_MSG
+
+    #####################################################
+    # Testing sensor_info
+    #
+    def test_sensor_info(self, run_command):
+        run_command.return_value = {"stdout": ipmitool_sdr_fan1, "stderr": ""}
+
+        output = sensor_info(None, {"sensors": "Fan1A"})
+        assert output == json.dumps(ipmitool_sdr_fan1_expected)
+
+    def test_sensor_info_wrong_sensor(self, run_command):
+        run_command.return_value = {
+            "stdout": ipmitool_sdr_fan1,
+            "stderr": "Unable to find sensor",
+        }
+
+        # When we try to get info about a sensor and an error occurs we just skip the sensor
+        # NOTE: An error log is also generated
+        with mock.patch("ipmitool._LOGGER") as mock_logger:
+            mock_logger.error = mock.Mock()
+
+            output = sensor_info(None, {"sensors": "Fan1A"})
+            assert output == json.dumps([])
+            mock_logger.error.assert_called_once_with("Unable to find sensor")
+
+    def test_sensor_info_normal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            SENSOR_INFO_CMD,
+            "",
+            IPMITOOL_NORMAL_ERROR,
+        )
+
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = sensor_info(None, {"sensors": "Fan1A"})
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == NORMAL_ERROR_MSG
+
+    def test_sensor_info_abnormal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            SENSOR_INFO_CMD,
+            "",
+            IPMITOOL_ABNORMAL_ERROR,
+        )
+
+        # We are expecting the exception to be raised again
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = sensor_info(None, {"sensors": "Fan1A"})
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == SENSOR_INFO_ABNORMAL_ERROR_MSG
+
+    #####################################################
+    # Testing ipmi_lan
+    #
+    def test_ipmi_lan(self, run_command):
+        run_command.return_value = {"stdout": ipmitool_lan_print}
+
+        output = ipmi_lan(None, None)
+        assert output == json.dumps(ipmitool_lan_print_expected)
+
+    def test_ipmi_lan_normal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            IPMI_LAN_CMD,
+            "",
+            IPMITOOL_NORMAL_ERROR,
+        )
+
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = ipmi_lan(None, None)
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == NORMAL_ERROR_MSG
+
+    def test_ipmi_lan_abnormal_error(self, run_command):
+        run_command.side_effect = ProcessException(
+            1,
+            IPMI_LAN_CMD,
+            "",
+            IPMITOOL_ABNORMAL_ERROR,
+        )
+
+        # We are expecting the exception to be raised again
+        with pytest.raises(XenAPIPlugin.Failure) as e:
+            _ = ipmi_lan(None, {"sensors": "Fan1A"})
+
+        assert e.value.params[0] == "1"
+        assert e.value.params[1] == IPMI_LAN_ABNORMAL_ERROR_MSG