From 2aca56a88d00e17cfef15bf150962001905b320a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Dec 2020 21:36:44 +0800 Subject: [PATCH] add plugin inputs.ipmi_power --- go.mod | 3 + plugins/inputs/all/all.go | 1 + plugins/inputs/ipmi_power/README.md | 144 ++++ plugins/inputs/ipmi_power/connection.go | 91 +++ plugins/inputs/ipmi_power/connection_test.go | 44 ++ plugins/inputs/ipmi_power/ipmi.go | 209 ++++++ plugins/inputs/ipmi_power/ipmi_test.go.bak | 750 +++++++++++++++++++ 7 files changed, 1242 insertions(+) create mode 100644 plugins/inputs/ipmi_power/README.md create mode 100644 plugins/inputs/ipmi_power/connection.go create mode 100644 plugins/inputs/ipmi_power/connection_test.go create mode 100644 plugins/inputs/ipmi_power/ipmi.go create mode 100644 plugins/inputs/ipmi_power/ipmi_test.go.bak diff --git a/go.mod b/go.mod index f2d7a3ec..3764c613 100644 --- a/go.mod +++ b/go.mod @@ -158,3 +158,6 @@ require ( // replaced due to https://github.com/satori/go.uuid/issues/73 replace github.com/satori/go.uuid => github.com/gofrs/uuid v3.2.0+incompatible + +replace github.com/influxdata/telegraf/plugins/inputs/ => ./plugins/inputs/ipmi_power + diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index 6eb5dbb7..ae546c85 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -189,4 +189,5 @@ import ( _ "github.com/influxdata/telegraf/plugins/inputs/zfs" _ "github.com/influxdata/telegraf/plugins/inputs/zipkin" _ "github.com/influxdata/telegraf/plugins/inputs/zookeeper" + _ "github.com/influxdata/telegraf/plugins/inputs/ipmi_power" ) diff --git a/plugins/inputs/ipmi_power/README.md b/plugins/inputs/ipmi_power/README.md new file mode 100644 index 00000000..0f9faa97 --- /dev/null +++ b/plugins/inputs/ipmi_power/README.md @@ -0,0 +1,144 @@ +# IPMI Sensor Input Plugin + +Get bare metal metrics using the command line utility +[`ipmitool`](https://github.com/ipmitool/ipmitool). + +If no servers are specified, the plugin will query the local machine sensor stats via the following command: + +``` +ipmitool sdr +``` +or with the version 2 schema: +``` +ipmitool sdr elist +``` + +When one or more servers are specified, the plugin will use the following command to collect remote host sensor stats: + +``` +ipmitool -I lan -H SERVER -U USERID -P PASSW0RD sdr +``` + +### Configuration + +```toml +# Read metrics from the bare metal servers via IPMI +[[inputs.ipmi_sensor]] + ## optionally specify the path to the ipmitool executable + # path = "/usr/bin/ipmitool" + ## + ## Setting 'use_sudo' to true will make use of sudo to run ipmitool. + ## Sudo must be configured to allow the telegraf user to run ipmitool + ## without a password. + # use_sudo = false + ## + ## optionally force session privilege level. Can be CALLBACK, USER, OPERATOR, ADMINISTRATOR + # privilege = "ADMINISTRATOR" + ## + ## optionally specify one or more servers via a url matching + ## [username[:password]@][protocol[(address)]] + ## e.g. + ## root:passwd@lan(127.0.0.1) + ## + ## if no servers are specified, local machine sensor stats will be queried + ## + # servers = ["USERID:PASSW0RD@lan(192.168.1.1)"] + + ## Recommended: use metric 'interval' that is a multiple of 'timeout' to avoid + ## gaps or overlap in pulled data + interval = "30s" + + ## Timeout for the ipmitool command to complete. Default is 20 seconds. + timeout = "20s" + + ## Schema Version: (Optional, defaults to version 1) + metric_version = 2 +``` + +### Measurements + +Version 1 schema: +- ipmi_sensor: + - tags: + - name + - unit + - host + - server (only when retrieving stats from remote servers) + - fields: + - status (int, 1=ok status_code/0=anything else) + - value (float) + +Version 2 schema: +- ipmi_sensor: + - tags: + - name + - entity_id (can help uniquify duplicate names) + - status_code (two letter code from IPMI documentation) + - status_desc (extended status description field) + - unit (only on analog values) + - host + - server (only when retrieving stats from remote) + - fields: + - value (float) + +#### Permissions + +When gathering from the local system, Telegraf will need permission to the +ipmi device node. When using udev you can create the device node giving +`rw` permissions to the `telegraf` user by adding the following rule to +`/etc/udev/rules.d/52-telegraf-ipmi.rules`: + +``` +KERNEL=="ipmi*", MODE="660", GROUP="telegraf" +``` +Alternatively, it is possible to use sudo. You will need the following in your telegraf config: +```toml +[[inputs.ipmi_sensor]] + use_sudo = true +``` + +You will also need to update your sudoers file: + +```bash +$ visudo +# Add the following line: +Cmnd_Alias IPMITOOL = /usr/bin/ipmitool * +telegraf ALL=(root) NOPASSWD: IPMITOOL +Defaults!IPMITOOL !logfile, !syslog, !pam_session +``` + +### Example Output + +#### Version 1 Schema +When retrieving stats from a remote server: +``` +ipmi_sensor,server=10.20.2.203,name=uid_light value=0,status=1i 1517125513000000000 +ipmi_sensor,server=10.20.2.203,name=sys._health_led status=1i,value=0 1517125513000000000 +ipmi_sensor,server=10.20.2.203,name=power_supply_1,unit=watts status=1i,value=110 1517125513000000000 +ipmi_sensor,server=10.20.2.203,name=power_supply_2,unit=watts status=1i,value=120 1517125513000000000 +ipmi_sensor,server=10.20.2.203,name=power_supplies value=0,status=1i 1517125513000000000 +ipmi_sensor,server=10.20.2.203,name=fan_1,unit=percent status=1i,value=43.12 1517125513000000000 +``` + + +When retrieving stats from the local machine (no server specified): +``` +ipmi_sensor,name=uid_light value=0,status=1i 1517125513000000000 +ipmi_sensor,name=sys._health_led status=1i,value=0 1517125513000000000 +ipmi_sensor,name=power_supply_1,unit=watts status=1i,value=110 1517125513000000000 +ipmi_sensor,name=power_supply_2,unit=watts status=1i,value=120 1517125513000000000 +ipmi_sensor,name=power_supplies value=0,status=1i 1517125513000000000 +ipmi_sensor,name=fan_1,unit=percent status=1i,value=43.12 1517125513000000000 +``` + +#### Version 2 Schema + +When retrieving stats from the local machine (no server specified): +``` +ipmi_sensor,name=uid_light,entity_id=23.1,status_code=ok,status_desc=ok value=0 1517125474000000000 +ipmi_sensor,name=sys._health_led,entity_id=23.2,status_code=ok,status_desc=ok value=0 1517125474000000000 +ipmi_sensor,entity_id=10.1,name=power_supply_1,status_code=ok,status_desc=presence_detected,unit=watts value=110 1517125474000000000 +ipmi_sensor,name=power_supply_2,entity_id=10.2,status_code=ok,unit=watts,status_desc=presence_detected value=125 1517125474000000000 +ipmi_sensor,name=power_supplies,entity_id=10.3,status_code=ok,status_desc=fully_redundant value=0 1517125474000000000 +ipmi_sensor,entity_id=7.1,name=fan_1,status_code=ok,status_desc=transition_to_running,unit=percent value=43.12 1517125474000000000 +``` diff --git a/plugins/inputs/ipmi_power/connection.go b/plugins/inputs/ipmi_power/connection.go new file mode 100644 index 00000000..f46925c4 --- /dev/null +++ b/plugins/inputs/ipmi_power/connection.go @@ -0,0 +1,91 @@ +package ipmi_power + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// Connection properties for a Client +type Connection struct { + Hostname string + Username string + Password string + Port int + Interface string + Privilege string +} + +func NewConnection(server string, privilege string) *Connection { + conn := &Connection{} + conn.Privilege = privilege + inx1 := strings.LastIndex(server, "@") + inx2 := strings.Index(server, "(") + + connstr := server + + if inx1 > 0 { + security := server[0:inx1] + connstr = server[inx1+1:] + up := strings.SplitN(security, ":", 2) + conn.Username = up[0] + conn.Password = up[1] + } + + if inx2 > 0 { + inx2 = strings.Index(connstr, "(") + inx3 := strings.Index(connstr, ")") + + conn.Interface = connstr[0:inx2] + conn.Hostname = connstr[inx2+1 : inx3] + } + + return conn +} + +func (t *Connection) options() []string { + intf := t.Interface + if intf == "" { + intf = "lan" + } + + options := []string{ + "-H", t.Hostname, + "-U", t.Username, + "-P", t.Password, + "-I", intf, + } + + if t.Port != 0 { + options = append(options, "-p", strconv.Itoa(t.Port)) + } + if t.Privilege != "" { + options = append(options, "-L", t.Privilege) + } + return options +} + +// RemoteIP returns the remote (bmc) IP address of the Connection +func (c *Connection) RemoteIP() string { + if net.ParseIP(c.Hostname) == nil { + addrs, err := net.LookupHost(c.Hostname) + if err != nil && len(addrs) > 0 { + return addrs[0] + } + } + return c.Hostname +} + +// LocalIP returns the local (client) IP address of the Connection +func (c *Connection) LocalIP() string { + conn, err := net.Dial("udp", fmt.Sprintf("%s:%d", c.Hostname, c.Port)) + if err != nil { + // don't bother returning an error, since this value will never + // make it to the bmc if we can't connect to it. + return c.Hostname + } + _ = conn.Close() + host, _, _ := net.SplitHostPort(conn.LocalAddr().String()) + return host +} diff --git a/plugins/inputs/ipmi_power/connection_test.go b/plugins/inputs/ipmi_power/connection_test.go new file mode 100644 index 00000000..3e69b8c6 --- /dev/null +++ b/plugins/inputs/ipmi_power/connection_test.go @@ -0,0 +1,44 @@ +package ipmi_power + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type conTest struct { + Got string + Want *Connection +} + +func TestNewConnection(t *testing.T) { + testData := []struct { + addr string + con *Connection + }{ + { + "USERID:PASSW0RD@lan(192.168.1.1)", + &Connection{ + Hostname: "192.168.1.1", + Username: "USERID", + Password: "PASSW0RD", + Interface: "lan", + Privilege: "USER", + }, + }, + { + "USERID:PASS:!@#$%^&*(234)_+W0RD@lan(192.168.1.1)", + &Connection{ + Hostname: "192.168.1.1", + Username: "USERID", + Password: "PASS:!@#$%^&*(234)_+W0RD", + Interface: "lan", + Privilege: "USER", + }, + }, + } + + for _, v := range testData { + assert.Equal(t, v.con, NewConnection(v.addr, "USER")) + } +} diff --git a/plugins/inputs/ipmi_power/ipmi.go b/plugins/inputs/ipmi_power/ipmi.go new file mode 100644 index 00000000..d0cae10d --- /dev/null +++ b/plugins/inputs/ipmi_power/ipmi.go @@ -0,0 +1,209 @@ +package ipmi_power + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal" + "github.com/influxdata/telegraf/plugins/inputs" +) + +var ( + execCommand = exec.Command // execCommand is used to mock commands in tests. + re_parse_line = regexp.MustCompile(`^\s+(?P[^:]*):\s+(?P\S+)\s+(?P\S+)`) +) + +// Ipmi stores the configuration values for the ipmi_power input plugin +type Ipmi struct { + Path string + Privilege string + Servers []string + Timeout internal.Duration + UseSudo bool + SamplePeriod string +} + +var sampleConfig = ` + ## optionally specify the path to the ipmitool executable + # path = "/usr/bin/ipmitool" + ## + ## Setting 'use_sudo' to true will make use of sudo to run ipmitool. + ## Sudo must be configured to allow the telegraf user to run ipmitool + ## without a password. + # use_sudo = false + ## + ## optionally force session privilege level. Can be CALLBACK, USER, OPERATOR, ADMINISTRATOR + # privilege = "ADMINISTRATOR" + ## + ## optionally specify one or more servers via a url matching + ## [username[:password]@][protocol[(address)]] + ## e.g. + ## root:passwd@lan(127.0.0.1) + ## + ## if no servers are specified, local machine sensor stats will be queried + ## + # servers = ["USERID:PASSW0RD@lan(192.168.1.1)"] + + ## Recommended: use metric 'interval' that is a multiple of 'timeout' to avoid + ## gaps or overlap in pulled data + interval = "30s" + + ## Timeout for the ipmitool command to complete + timeout = "20s" + + ## Sample Period, can be 5_sec/15_sec/30_sec/1_min/3_min/7_min/15_min/30_min/1_hour + # sample_period = "" +` + +// SampleConfig returns the documentation about the sample configuration +func (m *Ipmi) SampleConfig() string { + return sampleConfig +} + +// Description returns a basic description for the plugin functions +func (m *Ipmi) Description() string { + return "Read metrics from the bare metal servers via IPMI" +} + +// Gather is the main execution function for the plugin +func (m *Ipmi) Gather(acc telegraf.Accumulator) error { + if len(m.Path) == 0 { + return fmt.Errorf("ipmitool not found: verify that ipmitool is installed and that ipmitool is in your PATH") + } + + if len(m.Servers) > 0 { + wg := sync.WaitGroup{} + for _, server := range m.Servers { + wg.Add(1) + go func(a telegraf.Accumulator, s string) { + defer wg.Done() + err := m.parse(a, s) + if err != nil { + a.AddError(err) + } + }(acc, server) + } + wg.Wait() + } else { + err := m.parse(acc, "") + if err != nil { + return err + } + } + + return nil +} + +func (m *Ipmi) parse(acc telegraf.Accumulator, server string) error { + opts := make([]string, 0) + hostname := "" + if server != "" { + conn := NewConnection(server, m.Privilege) + hostname = conn.Hostname + opts = conn.options() + } + opts = append(opts, "dcmi", "power", "reading") + + if m.SamplePeriod != "" { + opts = append(opts, m.SamplePeriod) + } + + name := m.Path + if m.UseSudo { + // -n - avoid prompting the user for input of any kind + opts = append([]string{"-n", name}, opts...) + name = "sudo" + } + cmd := execCommand(name, opts...) + out, err := internal.CombinedOutputTimeout(cmd, m.Timeout.Duration) + timestamp := time.Now() + if err != nil { + return fmt.Errorf("failed to run command %s: %s - %s", strings.Join(cmd.Args, " "), err, string(out)) + } + return parseInner(acc, hostname, out, timestamp) +} + +func parseInner(acc telegraf.Accumulator, hostname string, cmdOut []byte, measured_at time.Time) error { + // each line will look something like + // Planar VBAT | 3.05 Volts | ok + + fields := make(map[string]interface{}) + scanner := bufio.NewScanner(bytes.NewReader(cmdOut)) + for scanner.Scan() { + ipmiFields := extractFieldsFromRegex(re_parse_line, scanner.Text()) + if len(ipmiFields) != 3 { + continue + } + + key := transform(ipmiFields["name"]) + floatval, err := aToFloat(ipmiFields["value"]) + if err != nil { + continue + } + fields[key] = floatval + fields[key + "_unit"] = ipmiFields["unit"] + + } + + acc.AddFields("ipmi_power", fields, nil, measured_at) + + return scanner.Err() +} + +// extractFieldsFromRegex consumes a regex with named capture groups and returns a kvp map of strings with the results +func extractFieldsFromRegex(re *regexp.Regexp, input string) map[string]string { + submatches := re.FindStringSubmatch(input) + results := make(map[string]string) + subexpNames := re.SubexpNames() + if len(subexpNames) > len(submatches) { + log.Printf("D! No matches found in '%s'", input) + return results + } + for i, name := range subexpNames { + if name != input && name != "" && input != "" { + results[name] = trim(submatches[i]) + } + } + return results +} + +// aToFloat converts string representations of numbers to float64 values +func aToFloat(val string) (float64, error) { + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0.0, err + } + return f, nil +} + +func trim(s string) string { + return strings.TrimSpace(s) +} + +func transform(s string) string { + s = trim(s) + s = strings.ToLower(s) + return strings.Replace(s, " ", "_", -1) +} + +func init() { + m := Ipmi{} + path, _ := exec.LookPath("ipmitool") + if len(path) > 0 { + m.Path = path + } + m.Timeout = internal.Duration{Duration: time.Second * 20} + inputs.Add("ipmi_power", func() telegraf.Input { + m := m + return &m + }) +} diff --git a/plugins/inputs/ipmi_power/ipmi_test.go.bak b/plugins/inputs/ipmi_power/ipmi_test.go.bak new file mode 100644 index 00000000..bd5e02c1 --- /dev/null +++ b/plugins/inputs/ipmi_power/ipmi_test.go.bak @@ -0,0 +1,750 @@ +package ipmi_sensor + +import ( + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal" + "github.com/influxdata/telegraf/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGather(t *testing.T) { + i := &Ipmi{ + Servers: []string{"USERID:PASSW0RD@lan(192.168.1.1)"}, + Path: "ipmitool", + Privilege: "USER", + Timeout: internal.Duration{Duration: time.Second * 5}, + } + // overwriting exec commands with mock commands + execCommand = fakeExecCommand + var acc testutil.Accumulator + + err := acc.GatherError(i.Gather) + + require.NoError(t, err) + + assert.Equal(t, acc.NFields(), 262, "non-numeric measurements should be ignored") + + conn := NewConnection(i.Servers[0], i.Privilege) + assert.Equal(t, "USERID", conn.Username) + assert.Equal(t, "lan", conn.Interface) + + var testsWithServer = []struct { + fields map[string]interface{} + tags map[string]string + }{ + { + map[string]interface{}{ + "value": float64(20), + "status": int(1), + }, + map[string]string{ + "name": "ambient_temp", + "server": "192.168.1.1", + "unit": "degrees_c", + }, + }, + { + map[string]interface{}{ + "value": float64(80), + "status": int(1), + }, + map[string]string{ + "name": "altitude", + "server": "192.168.1.1", + "unit": "feet", + }, + }, + { + map[string]interface{}{ + "value": float64(210), + "status": int(1), + }, + map[string]string{ + "name": "avg_power", + "server": "192.168.1.1", + "unit": "watts", + }, + }, + { + map[string]interface{}{ + "value": float64(4.9), + "status": int(1), + }, + map[string]string{ + "name": "planar_5v", + "server": "192.168.1.1", + "unit": "volts", + }, + }, + { + map[string]interface{}{ + "value": float64(3.05), + "status": int(1), + }, + map[string]string{ + "name": "planar_vbat", + "server": "192.168.1.1", + "unit": "volts", + }, + }, + { + map[string]interface{}{ + "value": float64(2610), + "status": int(1), + }, + map[string]string{ + "name": "fan_1a_tach", + "server": "192.168.1.1", + "unit": "rpm", + }, + }, + { + map[string]interface{}{ + "value": float64(1775), + "status": int(1), + }, + map[string]string{ + "name": "fan_1b_tach", + "server": "192.168.1.1", + "unit": "rpm", + }, + }, + } + + for _, test := range testsWithServer { + acc.AssertContainsTaggedFields(t, "ipmi_sensor", test.fields, test.tags) + } + + i = &Ipmi{ + Path: "ipmitool", + Timeout: internal.Duration{Duration: time.Second * 5}, + } + + err = acc.GatherError(i.Gather) + require.NoError(t, err) + + var testsWithoutServer = []struct { + fields map[string]interface{} + tags map[string]string + }{ + { + map[string]interface{}{ + "value": float64(20), + "status": int(1), + }, + map[string]string{ + "name": "ambient_temp", + "unit": "degrees_c", + }, + }, + { + map[string]interface{}{ + "value": float64(80), + "status": int(1), + }, + map[string]string{ + "name": "altitude", + "unit": "feet", + }, + }, + { + map[string]interface{}{ + "value": float64(210), + "status": int(1), + }, + map[string]string{ + "name": "avg_power", + "unit": "watts", + }, + }, + { + map[string]interface{}{ + "value": float64(4.9), + "status": int(1), + }, + map[string]string{ + "name": "planar_5v", + "unit": "volts", + }, + }, + { + map[string]interface{}{ + "value": float64(3.05), + "status": int(1), + }, + map[string]string{ + "name": "planar_vbat", + "unit": "volts", + }, + }, + { + map[string]interface{}{ + "value": float64(2610), + "status": int(1), + }, + map[string]string{ + "name": "fan_1a_tach", + "unit": "rpm", + }, + }, + { + map[string]interface{}{ + "value": float64(1775), + "status": int(1), + }, + map[string]string{ + "name": "fan_1b_tach", + "unit": "rpm", + }, + }, + } + + for _, test := range testsWithoutServer { + acc.AssertContainsTaggedFields(t, "ipmi_sensor", test.fields, test.tags) + } +} + +// fackeExecCommand is a helper function that mock +// the exec.Command call (and call the test binary) +func fakeExecCommand(command string, args ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", command} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} + return cmd +} + +// TestHelperProcess isn't a real test. It's used to mock exec.Command +// For example, if you run: +// GO_WANT_HELPER_PROCESS=1 go test -test.run=TestHelperProcess -- chrony tracking +// it returns below mockData. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + mockData := `Ambient Temp | 20 degrees C | ok +Altitude | 80 feet | ok +Avg Power | 210 Watts | ok +Planar 3.3V | 3.29 Volts | ok +Planar 5V | 4.90 Volts | ok +Planar 12V | 12.04 Volts | ok +Planar VBAT | 3.05 Volts | ok +Fan 1A Tach | 2610 RPM | ok +Fan 1B Tach | 1775 RPM | ok +Fan 2A Tach | 2001 RPM | ok +Fan 2B Tach | 1275 RPM | ok +Fan 3A Tach | 2929 RPM | ok +Fan 3B Tach | 2125 RPM | ok +Fan 1 | 0x00 | ok +Fan 2 | 0x00 | ok +Fan 3 | 0x00 | ok +Front Panel | 0x00 | ok +Video USB | 0x00 | ok +DASD Backplane 1 | 0x00 | ok +SAS Riser | 0x00 | ok +PCI Riser 1 | 0x00 | ok +PCI Riser 2 | 0x00 | ok +CPU 1 | 0x00 | ok +CPU 2 | 0x00 | ok +All CPUs | 0x00 | ok +One of The CPUs | 0x00 | ok +IOH Temp Status | 0x00 | ok +CPU 1 OverTemp | 0x00 | ok +CPU 2 OverTemp | 0x00 | ok +CPU Fault Reboot | 0x00 | ok +Aux Log | 0x00 | ok +NMI State | 0x00 | ok +ABR Status | 0x00 | ok +Firmware Error | 0x00 | ok +PCIs | 0x00 | ok +CPUs | 0x00 | ok +DIMMs | 0x00 | ok +Sys Board Fault | 0x00 | ok +Power Supply 1 | 0x00 | ok +Power Supply 2 | 0x00 | ok +PS 1 Fan Fault | 0x00 | ok +PS 2 Fan Fault | 0x00 | ok +VT Fault | 0x00 | ok +Pwr Rail A Fault | 0x00 | ok +Pwr Rail B Fault | 0x00 | ok +Pwr Rail C Fault | 0x00 | ok +Pwr Rail D Fault | 0x00 | ok +Pwr Rail E Fault | 0x00 | ok +PS 1 Therm Fault | 0x00 | ok +PS 2 Therm Fault | 0x00 | ok +PS1 12V OV Fault | 0x00 | ok +PS2 12V OV Fault | 0x00 | ok +PS1 12V UV Fault | 0x00 | ok +PS2 12V UV Fault | 0x00 | ok +PS1 12V OC Fault | 0x00 | ok +PS2 12V OC Fault | 0x00 | ok +PS 1 VCO Fault | 0x00 | ok +PS 2 VCO Fault | 0x00 | ok +Power Unit | 0x00 | ok +Cooling Zone 1 | 0x00 | ok +Cooling Zone 2 | 0x00 | ok +Cooling Zone 3 | 0x00 | ok +Drive 0 | 0x00 | ok +Drive 1 | 0x00 | ok +Drive 2 | 0x00 | ok +Drive 3 | 0x00 | ok +Drive 4 | 0x00 | ok +Drive 5 | 0x00 | ok +Drive 6 | 0x00 | ok +Drive 7 | 0x00 | ok +Drive 8 | 0x00 | ok +Drive 9 | 0x00 | ok +Drive 10 | 0x00 | ok +Drive 11 | 0x00 | ok +Drive 12 | 0x00 | ok +Drive 13 | 0x00 | ok +Drive 14 | 0x00 | ok +Drive 15 | 0x00 | ok +All DIMMS | 0x00 | ok +One of the DIMMs | 0x00 | ok +DIMM 1 | 0x00 | ok +DIMM 2 | 0x00 | ok +DIMM 3 | 0x00 | ok +DIMM 4 | 0x00 | ok +DIMM 5 | 0x00 | ok +DIMM 6 | 0x00 | ok +DIMM 7 | 0x00 | ok +DIMM 8 | 0x00 | ok +DIMM 9 | 0x00 | ok +DIMM 10 | 0x00 | ok +DIMM 11 | 0x00 | ok +DIMM 12 | 0x00 | ok +DIMM 13 | 0x00 | ok +DIMM 14 | 0x00 | ok +DIMM 15 | 0x00 | ok +DIMM 16 | 0x00 | ok +DIMM 17 | 0x00 | ok +DIMM 18 | 0x00 | ok +DIMM 1 Temp | 0x00 | ok +DIMM 2 Temp | 0x00 | ok +DIMM 3 Temp | 0x00 | ok +DIMM 4 Temp | 0x00 | ok +DIMM 5 Temp | 0x00 | ok +DIMM 6 Temp | 0x00 | ok +DIMM 7 Temp | 0x00 | ok +DIMM 8 Temp | 0x00 | ok +DIMM 9 Temp | 0x00 | ok +DIMM 10 Temp | 0x00 | ok +DIMM 11 Temp | 0x00 | ok +DIMM 12 Temp | 0x00 | ok +DIMM 13 Temp | 0x00 | ok +DIMM 14 Temp | 0x00 | ok +DIMM 15 Temp | 0x00 | ok +DIMM 16 Temp | 0x00 | ok +DIMM 17 Temp | 0x00 | ok +DIMM 18 Temp | 0x00 | ok +PCI 1 | 0x00 | ok +PCI 2 | 0x00 | ok +PCI 3 | 0x00 | ok +PCI 4 | 0x00 | ok +All PCI Error | 0x00 | ok +One of PCI Error | 0x00 | ok +IPMI Watchdog | 0x00 | ok +Host Power | 0x00 | ok +DASD Backplane 2 | 0x00 | ok +DASD Backplane 3 | Not Readable | ns +DASD Backplane 4 | Not Readable | ns +Backup Memory | 0x00 | ok +Progress | 0x00 | ok +Planar Fault | 0x00 | ok +SEL Fullness | 0x00 | ok +PCI 5 | 0x00 | ok +OS RealTime Mod | 0x00 | ok +` + + args := os.Args + + // Previous arguments are tests stuff, that looks like : + // /tmp/go-build970079519/…/_test/integration.test -test.run=TestHelperProcess -- + cmd, args := args[3], args[4:] + + if cmd == "ipmitool" { + fmt.Fprint(os.Stdout, mockData) + } else { + fmt.Fprint(os.Stdout, "command not found") + os.Exit(1) + + } + os.Exit(0) +} + +func TestGatherV2(t *testing.T) { + i := &Ipmi{ + Servers: []string{"USERID:PASSW0RD@lan(192.168.1.1)"}, + Path: "ipmitool", + Privilege: "USER", + Timeout: internal.Duration{Duration: time.Second * 5}, + MetricVersion: 2, + } + // overwriting exec commands with mock commands + execCommand = fakeExecCommandV2 + var acc testutil.Accumulator + + err := acc.GatherError(i.Gather) + + require.NoError(t, err) + + conn := NewConnection(i.Servers[0], i.Privilege) + assert.Equal(t, "USERID", conn.Username) + assert.Equal(t, "lan", conn.Interface) + + var testsWithServer = []struct { + fields map[string]interface{} + tags map[string]string + }{ + //SEL | 72h | ns | 7.1 | No Reading + { + map[string]interface{}{ + "value": float64(0), + }, + map[string]string{ + "name": "sel", + "entity_id": "7.1", + "status_code": "ns", + "status_desc": "no_reading", + "server": "192.168.1.1", + }, + }, + } + + for _, test := range testsWithServer { + acc.AssertContainsTaggedFields(t, "ipmi_sensor", test.fields, test.tags) + } + + i = &Ipmi{ + Path: "ipmitool", + Timeout: internal.Duration{Duration: time.Second * 5}, + MetricVersion: 2, + } + + err = acc.GatherError(i.Gather) + require.NoError(t, err) + + var testsWithoutServer = []struct { + fields map[string]interface{} + tags map[string]string + }{ + //SEL | 72h | ns | 7.1 | No Reading + { + map[string]interface{}{ + "value": float64(0), + }, + map[string]string{ + "name": "sel", + "entity_id": "7.1", + "status_code": "ns", + "status_desc": "no_reading", + }, + }, + //Intrusion | 73h | ok | 7.1 | + { + map[string]interface{}{ + "value": float64(0), + }, + map[string]string{ + "name": "intrusion", + "entity_id": "7.1", + "status_code": "ok", + "status_desc": "ok", + }, + }, + //Fan1 | 30h | ok | 7.1 | 5040 RPM + { + map[string]interface{}{ + "value": float64(5040), + }, + map[string]string{ + "name": "fan1", + "entity_id": "7.1", + "status_code": "ok", + "unit": "rpm", + }, + }, + //Inlet Temp | 04h | ok | 7.1 | 25 degrees C + { + map[string]interface{}{ + "value": float64(25), + }, + map[string]string{ + "name": "inlet_temp", + "entity_id": "7.1", + "status_code": "ok", + "unit": "degrees_c", + }, + }, + //USB Cable Pres | 50h | ok | 7.1 | Connected + { + map[string]interface{}{ + "value": float64(0), + }, + map[string]string{ + "name": "usb_cable_pres", + "entity_id": "7.1", + "status_code": "ok", + "status_desc": "connected", + }, + }, + //Current 1 | 6Ah | ok | 10.1 | 7.20 Amps + { + map[string]interface{}{ + "value": float64(7.2), + }, + map[string]string{ + "name": "current_1", + "entity_id": "10.1", + "status_code": "ok", + "unit": "amps", + }, + }, + //Power Supply 1 | 03h | ok | 10.1 | 110 Watts, Presence detected + { + map[string]interface{}{ + "value": float64(110), + }, + map[string]string{ + "name": "power_supply_1", + "entity_id": "10.1", + "status_code": "ok", + "unit": "watts", + "status_desc": "presence_detected", + }, + }, + } + + for _, test := range testsWithoutServer { + acc.AssertContainsTaggedFields(t, "ipmi_sensor", test.fields, test.tags) + } +} + +// fackeExecCommandV2 is a helper function that mock +// the exec.Command call (and call the test binary) +func fakeExecCommandV2(command string, args ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcessV2", "--", command} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} + return cmd +} + +// TestHelperProcessV2 isn't a real test. It's used to mock exec.Command +// For example, if you run: +// GO_WANT_HELPER_PROCESS=1 go test -test.run=TestHelperProcessV2 -- chrony tracking +// it returns below mockData. +func TestHelperProcessV2(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + // Curated list of use cases instead of full dumps + mockData := `SEL | 72h | ns | 7.1 | No Reading +Intrusion | 73h | ok | 7.1 | +Fan1 | 30h | ok | 7.1 | 5040 RPM +Inlet Temp | 04h | ok | 7.1 | 25 degrees C +USB Cable Pres | 50h | ok | 7.1 | Connected +Current 1 | 6Ah | ok | 10.1 | 7.20 Amps +Power Supply 1 | 03h | ok | 10.1 | 110 Watts, Presence detected +` + + args := os.Args + + // Previous arguments are tests stuff, that looks like : + // /tmp/go-build970079519/…/_test/integration.test -test.run=TestHelperProcess -- + cmd, args := args[3], args[4:] + + if cmd == "ipmitool" { + fmt.Fprint(os.Stdout, mockData) + } else { + fmt.Fprint(os.Stdout, "command not found") + os.Exit(1) + + } + os.Exit(0) +} + +func TestExtractFields(t *testing.T) { + v1Data := `Ambient Temp | 20 degrees C | ok +Altitude | 80 feet | ok +Avg Power | 210 Watts | ok +Planar 3.3V | 3.29 Volts | ok +Planar 5V | 4.90 Volts | ok +Planar 12V | 12.04 Volts | ok +B | 0x00 | ok +Unable to send command: Invalid argument +ECC Corr Err | Not Readable | ns +Unable to send command: Invalid argument +ECC Uncorr Err | Not Readable | ns +Unable to send command: Invalid argument +` + + v2Data := `SEL | 72h | ns | 7.1 | No Reading +Intrusion | 73h | ok | 7.1 | +Fan1 | 30h | ok | 7.1 | 5040 RPM +Inlet Temp | 04h | ok | 7.1 | 25 degrees C +USB Cable Pres | 50h | ok | 7.1 | Connected +Unable to send command: Invalid argument +Current 1 | 6Ah | ok | 10.1 | 7.20 Amps +Unable to send command: Invalid argument +Power Supply 1 | 03h | ok | 10.1 | 110 Watts, Presence detected +` + + tests := []string{ + v1Data, + v2Data, + } + + for i := range tests { + t.Logf("Checking v%d data...", i+1) + extractFieldsFromRegex(re_v1_parse_line, tests[i]) + extractFieldsFromRegex(re_v2_parse_line, tests[i]) + } +} + +func Test_parseV1(t *testing.T) { + type args struct { + hostname string + cmdOut []byte + measuredAt time.Time + } + tests := []struct { + name string + args args + wantFields map[string]interface{} + wantErr bool + }{ + { + name: "Test correct V1 parsing with hex code", + args: args{ + hostname: "host", + measuredAt: time.Now(), + cmdOut: []byte("PS1 Status | 0x02 | ok"), + }, + wantFields: map[string]interface{}{"value": float64(2), "status": 1}, + wantErr: false, + }, + { + name: "Test correct V1 parsing with value with unit", + args: args{ + hostname: "host", + measuredAt: time.Now(), + cmdOut: []byte("Avg Power | 210 Watts | ok"), + }, + wantFields: map[string]interface{}{"value": float64(210), "status": 1}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var acc testutil.Accumulator + + if err := parseV1(&acc, tt.args.hostname, tt.args.cmdOut, tt.args.measuredAt); (err != nil) != tt.wantErr { + t.Errorf("parseV1() error = %v, wantErr %v", err, tt.wantErr) + } + + acc.AssertContainsFields(t, "ipmi_sensor", tt.wantFields) + }) + } +} + +func Test_parseV2(t *testing.T) { + type args struct { + hostname string + cmdOut []byte + measuredAt time.Time + } + tests := []struct { + name string + args args + expected []telegraf.Metric + wantErr bool + }{ + { + name: "Test correct V2 parsing with analog value with unit", + args: args{ + hostname: "host", + cmdOut: []byte("Power Supply 1 | 03h | ok | 10.1 | 110 Watts, Presence detected"), + measuredAt: time.Now(), + }, + expected: []telegraf.Metric{ + testutil.MustMetric("ipmi_sensor", + map[string]string{ + "name": "power_supply_1", + "status_code": "ok", + "server": "host", + "entity_id": "10.1", + "unit": "watts", + "status_desc": "presence_detected", + }, + map[string]interface{}{"value": 110.0}, + time.Unix(0, 0), + ), + }, + wantErr: false, + }, + { + name: "Test correct V2 parsing without analog value", + args: args{ + hostname: "host", + cmdOut: []byte("Intrusion | 73h | ok | 7.1 |"), + measuredAt: time.Now(), + }, + expected: []telegraf.Metric{ + testutil.MustMetric("ipmi_sensor", + map[string]string{ + "name": "intrusion", + "status_code": "ok", + "server": "host", + "entity_id": "7.1", + "status_desc": "ok", + }, + map[string]interface{}{"value": 0.0}, + time.Unix(0, 0), + ), + }, + wantErr: false, + }, + { + name: "parse negative value", + args: args{ + hostname: "host", + cmdOut: []byte("DIMM Thrm Mrgn 1 | B0h | ok | 8.1 | -55 degrees C"), + measuredAt: time.Now(), + }, + expected: []telegraf.Metric{ + testutil.MustMetric("ipmi_sensor", + map[string]string{ + "name": "dimm_thrm_mrgn_1", + "status_code": "ok", + "server": "host", + "entity_id": "8.1", + "unit": "degrees_c", + }, + map[string]interface{}{"value": -55.0}, + time.Unix(0, 0), + ), + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var acc testutil.Accumulator + if err := parseV2(&acc, tt.args.hostname, tt.args.cmdOut, tt.args.measuredAt); (err != nil) != tt.wantErr { + t.Errorf("parseV2() error = %v, wantErr %v", err, tt.wantErr) + } + testutil.RequireMetricsEqual(t, tt.expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime()) + }) + } +}