Add HP Smart Storage Array python plugin (#7181)
* Add HP Smart Storage Array python plugin * Use sudo -l to check if the netdata user is allowed to execute the ssacli command * Don't prepend protected functions and properties with an underscore * Use match_any() helper instead of or to match with any regex * Simplify HPSSA.parse_logical_drive() * Document hpssa specific configuration parameters * Improve README.md with suggestions
Peter Gnodde committed
Dec 6, 2019 at 14:36 UTC
55bdae1356071f8f9a263a5685ca609d826be13c
6 files changed
+512
collectors/python.d.plugin/Makefile.am
+1
@@ -64,6 +64,7 @@ include go_expvar/Makefile.inc
64
include haproxy/Makefile.inc
65
include hddtemp/Makefile.inc
66
include httpcheck/Makefile.inc
67
+include hpssa/Makefile.inc
68
include icecast/Makefile.inc
69
include ipfs/Makefile.inc
70
include isc_dhcpd/Makefile.inc
collectors/python.d.plugin/hpssa/Makefile.inc
new
+13
@@ -0,0 +1,13 @@
1
+# SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+# THIS IS NOT A COMPLETE Makefile
4
+# IT IS INCLUDED BY ITS PARENT'S Makefile.am
5
+# IT IS REQUIRED TO REFERENCE ALL FILES RELATIVE TO THE PARENT
6
+
7
+# install these files
8
+dist_python_DATA += hpssa/hpssa.chart.py
9
+dist_pythonconfig_DATA += hpssa/hpssa.conf
10
+
11
+# do not install these files, but include them in the distribution
12
+dist_noinst_DATA += hpssa/README.md hpssa/Makefile.inc
13
+
collectors/python.d.plugin/hpssa/README.md
new
+44
@@ -0,0 +1,44 @@
1
+# hpssa
2
+
3
+This module collects controller, logical, and physical device health metrics from HP Smart Storage Arrays.
4
+
5
+## Requirements:
6
+
7
+- `ssacli` program
8
+- `sudo` program
9
+- `netdata` user needs to be able to sudo the `ssacli` program without password
10
+
11
+To collect metrics, the module executes: `sudo -n ssacli ctrl all show config detail`
12
+
13
+This module produces:
14
+
15
+1. Controller state and temperature
16
+2. Cache module state and temperature
17
+3. Logical drive state
18
+4. Physical drive state and temperature
19
+
20
+## Prerequisite
21
+
22
+This module uses `ssacli`, which can only be executed by root. It uses
23
+`sudo` and assumes that it is configured such that the `netdata` user can
24
+execute `ssacli` as root without password.
25
+
26
+Add to `sudoers`:
27
+
28
+```
29
+netdata ALL=(root) NOPASSWD: /path/to/ssacli
30
+```
31
+
32
+## Configuration
33
+
34
+**hpssa** is disabled by default. Should be explicitly enabled in `python.d.conf`.
35
+
36
+```yaml
37
+hpssa: yes
38
+```
39
+
40
+If `ssacli` cannot be found in the `PATH`, configure it in `hpssa.conf`.
41
+
42
+```yaml
43
+ssacli_path: /usr/sbin/ssacli
44
+```
collectors/python.d.plugin/hpssa/hpssa.chart.py
new
+392
@@ -0,0 +1,392 @@
1
+# -*- coding: utf-8 -*-
2
+# Description: hpssa netdata python.d module
3
+# Author: Peter Gnodde (gnoddep)
4
+# SPDX-License-Identifier: GPL-3.0-or-later
5
+
6
+import os
7
+import re
8
+
9
+from bases.FrameworkServices.ExecutableService import ExecutableService
10
+from bases.collection import find_binary
11
+from copy import deepcopy
12
+
13
+disabled_by_default = True
14
+update_every = 5
15
+
16
+ORDER = [
17
+ 'ctrl_status',
18
+ 'ctrl_temperature',
19
+ 'ld_status',
20
+ 'pd_status',
21
+ 'pd_temperature',
22
+]
23
+
24
+CHARTS = {
25
+ 'ctrl_status': {
26
+ 'options': [
27
+ None,
28
+ 'Status 1 is OK, Status 0 is not OK',
29
+ 'Status',
30
+ 'Controller',
31
+ 'hpssa.ctrl_status',
32
+ 'line'
33
+ ],
34
+ 'lines': []
35
+ },
36
+ 'ctrl_temperature': {
37
+ 'options': [
38
+ None,
39
+ 'Temperature',
40
+ 'Celsius',
41
+ 'Controller',
42
+ 'hpssa.ctrl_temperature',
43
+ 'line'
44
+ ],
45
+ 'lines': []
46
+ },
47
+ 'ld_status': {
48
+ 'options': [
49
+ None,
50
+ 'Status 1 is OK, Status 0 is not OK',
51
+ 'Status',
52
+ 'Logical drives',
53
+ 'hpssa.ld_status',
54
+ 'line'
55
+ ],
56
+ 'lines': []
57
+ },
58
+ 'pd_status': {
59
+ 'options': [
60
+ None,
61
+ 'Status 1 is OK, Status 0 is not OK',
62
+ 'Status',
63
+ 'Physical drives',
64
+ 'hpssa.pd_status',
65
+ 'line'
66
+ ],
67
+ 'lines': []
68
+ },
69
+ 'pd_temperature': {
70
+ 'options': [
71
+ None,
72
+ 'Temperature',
73
+ 'Celsius',
74
+ 'Physical drives',
75
+ 'hpssa.pd_temperature',
76
+ 'line'
77
+ ],
78
+ 'lines': []
79
+ }
80
+}
81
+
82
+adapter_regex = re.compile(r'^(?P<adapter_type>.+) in Slot (?P<slot>\d+)')
83
+ignored_sections_regex = re.compile(
84
+ r'''
85
+ ^
86
+ Physical[ ]Drives
87
+ | None[ ]attached
88
+ | (?:Expander|Enclosure|SEP|Port[ ]Name:)[ ].+
89
+ | .+[ ]at[ ]Port[ ]\S+,[ ]Box[ ]\d+,[ ].+
90
+ | Mirror[ ]Group[ ]\d+:
91
+ $
92
+ ''',
93
+ re.X
94
+)
95
+mirror_group_regex = re.compile(r'^Mirror Group \d+:$')
96
+array_regex = re.compile(r'^Array: (?P<id>[A-Z]+)$')
97
+drive_regex = re.compile(
98
+ r'''
99
+ ^
100
+ Logical[ ]Drive:[ ](?P<logical_drive_id>\d+)
101
+ | physicaldrive[ ](?P<fqn>[^:]+:\d+:\d+)
102
+ $
103
+ ''',
104
+ re.X
105
+)
106
+key_value_regex = re.compile(r'^(?P<key>[^:]+): ?(?P<value>.*)$')
107
+ld_status_regex = re.compile(r'^Status: (?P<status>[^,]+)(?:, (?P<percentage>[0-9.]+)% complete)?$')
108
+error_match = re.compile(r'Error:')
109
+
110
+
111
+class HPSSAException(Exception):
112
+ pass
113
+
114
+
115
+class HPSSA(object):
116
+ def __init__(self, lines):
117
+ self.lines = [line.strip() for line in lines if line.strip()]
118
+ self.current_line = 0
119
+ self.adapters = []
120
+ self.parse()
121
+
122
+ def __iter__(self):
123
+ return self
124
+
125
+ def __next__(self):
126
+ if self.current_line == len(self.lines):
127
+ raise StopIteration
128
+
129
+ line = self.lines[self.current_line]
130
+ self.current_line += 1
131
+
132
+ return line
133
+
134
+ def next(self):
135
+ """
136
+ This is for Python 2.7 compatibility
137
+ """
138
+ return self.__next__()
139
+
140
+ def rewind(self):
141
+ self.current_line = max(self.current_line - 1, 0)
142
+
143
+ @staticmethod
144
+ def match_any(line, *regexes):
145
+ return any([regex.match(line) for regex in regexes])
146
+
147
+ def parse(self):
148
+ for line in self:
149
+ match = adapter_regex.match(line)
150
+ if match:
151
+ self.adapters.append(self.parse_adapter(**match.groupdict()))
152
+
153
+ def parse_adapter(self, slot, adapter_type):
154
+ adapter = {
155
+ 'slot': int(slot),
156
+ 'type': adapter_type,
157
+
158
+ 'controller': {
159
+ 'status': None,
160
+ 'temperature': None,
161
+ },
162
+ 'cache': {
163
+ 'present': False,
164
+ 'status': None,
165
+ 'temperature': None,
166
+ },
167
+ 'battery': {
168
+ 'status': None,
169
+ 'count': 0,
170
+ },
171
+
172
+ 'logical_drives': [],
173
+ 'physical_drives': [],
174
+ }
175
+
176
+ for line in self:
177
+ if error_match.match(line):
178
+ raise HPSSAException('Error: {}'.format(line))
179
+ elif array_regex.match(line):
180
+ self.parse_array(adapter)
181
+ elif line == 'Unassigned':
182
+ self.parse_unassigned_physical_drives(adapter)
183
+ elif ignored_sections_regex.match(line):
184
+ self.parse_ignored_section()
185
+ else:
186
+ match = key_value_regex.match(line)
187
+ if match:
188
+ key, value = match.group('key', 'value')
189
+ if key == 'Controller Status':
190
+ adapter['controller']['status'] = value == 'OK'
191
+ elif key == 'Controller Temperature (C)':
192
+ adapter['controller']['temperature'] = int(value)
193
+ elif key == 'Cache Board Present':
194
+ adapter['cache']['present'] = value == 'True'
195
+ elif key == 'Cache Status':
196
+ adapter['cache']['status'] = value == 'OK'
197
+ elif key == 'Cache Module Temperature (C)':
198
+ adapter['cache']['temperature'] = int(value)
199
+ elif key == 'Battery/Capacitor Count':
200
+ adapter['battery']['count'] = int(value)
201
+ elif key == 'Battery/Capacitor Status':
202
+ adapter['battery']['status'] = value == 'OK'
203
+ else:
204
+ raise HPSSAException('Cannot parse line: {}'.format(line))
205
+
206
+ return adapter
207
+
208
+ def parse_array(self, adapter):
209
+ for line in self:
210
+ if HPSSA.match_any(line, adapter_regex, array_regex, ignored_sections_regex):
211
+ self.rewind()
212
+ break
213
+
214
+ match = drive_regex.match(line)
215
+ if match:
216
+ data = match.groupdict()
217
+ if data['logical_drive_id']:
218
+ self.parse_logical_drive(adapter, int(data['logical_drive_id']))
219
+ else:
220
+ self.parse_physical_drive(adapter, data['fqn'])
221
+ elif not key_value_regex.match(line):
222
+ self.rewind()
223
+ break
224
+
225
+ def parse_unassigned_physical_drives(self, adapter):
226
+ for line in self:
227
+ match = drive_regex.match(line)
228
+ if match:
229
+ self.parse_physical_drive(adapter, match.group('fqn'))
230
+ else:
231
+ self.rewind()
232
+ break
233
+
234
+ def parse_logical_drive(self, adapter, logical_drive_id):
235
+ ld = {
236
+ 'id': logical_drive_id,
237
+ 'status': None,
238
+ 'status_complete': None,
239
+ }
240
+
241
+ for line in self:
242
+ if mirror_group_regex.match(line):
243
+ self.parse_ignored_section()
244
+ continue
245
+
246
+ match = ld_status_regex.match(line)
247
+ if match:
248
+ ld['status'] = match.group('status') == 'OK'
249
+
250
+ if match.group('percentage'):
251
+ ld['status_complete'] = float(match.group('percentage')) / 100
252
+ elif HPSSA.match_any(line, adapter_regex, array_regex, drive_regex, ignored_sections_regex) \
253
+ or not key_value_regex.match(line):
254
+ self.rewind()
255
+ break
256
+
257
+ adapter['logical_drives'].append(ld)
258
+
259
+ def parse_physical_drive(self, adapter, fqn):
260
+ pd = {
261
+ 'fqn': fqn,
262
+ 'status': None,
263
+ 'temperature': None,
264
+ }
265
+
266
+ for line in self:
267
+ if HPSSA.match_any(line, adapter_regex, array_regex, drive_regex, ignored_sections_regex):
268
+ self.rewind()
269
+ break
270
+
271
+ match = key_value_regex.match(line)
272
+ if match:
273
+ key, value = match.group('key', 'value')
274
+ if key == 'Status':
275
+ pd['status'] = value == 'OK'
276
+ elif key == 'Current Temperature (C)':
277
+ pd['temperature'] = int(value)
278
+ else:
279
+ self.rewind()
280
+ break
281
+
282
+ adapter['physical_drives'].append(pd)
283
+
284
+ def parse_ignored_section(self):
285
+ for line in self:
286
+ if HPSSA.match_any(line, adapter_regex, array_regex, drive_regex, ignored_sections_regex) \
287
+ or not key_value_regex.match(line):
288
+ self.rewind()
289
+ break
290
+
291
+
292
+class Service(ExecutableService):
293
+ def __init__(self, configuration=None, name=None):
294
+ super(Service, self).__init__(configuration=configuration, name=name)
295
+ self.order = ORDER
296
+ self.definitions = deepcopy(CHARTS)
297
+ self.ssacli_path = self.configuration.get('ssacli_path', 'ssacli')
298
+ self.use_sudo = self.configuration.get('use_sudo', True)
299
+ self.cmd = []
300
+
301
+ def get_adapters(self):
302
+ try:
303
+ adapters = HPSSA(self._get_raw_data(command=self.cmd)).adapters
304
+ if not adapters:
305
+ # If no adapters are returned, run the command again but capture stderr
306
+ err = self._get_raw_data(command=self.cmd, stderr=True)
307
+ if err:
308
+ raise HPSSAException('Error executing cmd {}: {}'.format(' '.join(self.cmd), '\n'.join(err)))
309
+ return adapters
310
+ except HPSSAException as ex:
311
+ self.error(ex)
312
+ return []
313
+
314
+ def check(self):
315
+ if not os.path.isfile(self.ssacli_path):
316
+ ssacli_path = find_binary(self.ssacli_path)
317
+ if ssacli_path:
318
+ self.ssacli_path = ssacli_path
319
+ else:
320
+ self.error('Cannot locate "{}" binary'.format(self.ssacli_path))
321
+ return False
322
+
323
+ if self.use_sudo:
324
+ sudo = find_binary('sudo')
325
+ if not sudo:
326
+ self.error('Cannot locate "{}" binary'.format('sudo'))
327
+ return False
328
+
329
+ allowed = self._get_raw_data(command=[sudo, '-n', '-l', self.ssacli_path])
330
+ if not allowed or allowed[0].strip() != os.path.realpath(self.ssacli_path):
331
+ self.error('Not allowed to run sudo for command {}'.format(self.ssacli_path))
332
+ return False
333
+
334
+ self.cmd = [sudo, '-n']
335
+
336
+ self.cmd.extend([self.ssacli_path, 'ctrl', 'all', 'show', 'config', 'detail'])
337
+ self.info('Command: {}'.format(self.cmd))
338
+
339
+ adapters = self.get_adapters()
340
+
341
+ self.info('Discovered adapters: {}'.format([adapter['type'] for adapter in adapters]))
342
+ if not adapters:
343
+ self.error('No adapters discovered')
344
+ return False
345
+
346
+ return True
347
+
348
+ def get_data(self):
349
+ netdata = {}
350
+
351
+ for adapter in self.get_adapters():
352
+ status_key = '{}_status'.format(adapter['slot'])
353
+ temperature_key = '{}_temperature'.format(adapter['slot'])
354
+ ld_key = 'ld_{}_'.format(adapter['slot'])
355
+
356
+ data = {
357
+ 'ctrl_status': {
358
+ 'ctrl_' + status_key: adapter['controller']['status'],
359
+ 'cache_' + status_key: adapter['cache']['present'] and adapter['cache']['status'],
360
+ 'battery_' + status_key:
361
+ adapter['battery']['status'] if adapter['battery']['count'] > 0 else None
362
+ },
363
+
364
+ 'ctrl_temperature': {
365
+ 'ctrl_' + temperature_key: adapter['controller']['temperature'],
366
+ 'cache_' + temperature_key: adapter['cache']['temperature'],
367
+ },
368
+
369
+ 'ld_status': {
370
+ ld_key + '{}_status'.format(ld['id']): ld['status'] for ld in adapter['logical_drives']
371
+ },
372
+
373
+ 'pd_status': {},
374
+ 'pd_temperature': {},
375
+ }
376
+
377
+ for pd in adapter['physical_drives']:
378
+ pd_key = 'pd_{}_{}'.format(adapter['slot'], pd['fqn'])
379
+ data['pd_status'][pd_key + '_status'] = pd['status']
380
+ data['pd_temperature'][pd_key + '_temperature'] = pd['temperature']
381
+
382
+ for chart, dimension_data in data.items():
383
+ for dimension_id, value in dimension_data.items():
384
+ if value is None:
385
+ continue
386
+
387
+ if dimension_id not in self.charts[chart]:
388
+ self.charts[chart].add_dimension([dimension_id])
389
+
390
+ netdata[dimension_id] = value
391
+
392
+ return netdata
collectors/python.d.plugin/hpssa/hpssa.conf
new
+61
@@ -0,0 +1,61 @@
1
+# netdata python.d.plugin configuration for hpssa
2
+#
3
+# This file is in YaML format. Generally the format is:
4
+#
5
+# name: value
6
+#
7
+
8
+# ----------------------------------------------------------------------
9
+# Global Variables
10
+# These variables set the defaults for all JOBs, however each JOB
11
+# may define its own, overriding the defaults.
12
+
13
+# update_every sets the default data collection frequency.
14
+# If unset, the python.d.plugin default is used.
15
+# update_every: 5
16
+
17
+# priority controls the order of charts at the netdata dashboard.
18
+# Lower numbers move the charts towards the top of the page.
19
+# If unset, the default for python.d.plugin is used.
20
+# priority: 60000
21
+
22
+# penalty indicates whether to apply penalty to update_every in case of failures.
23
+# Penalty will increase every 5 failed updates in a row. Maximum penalty is 10 minutes.
24
+# penalty: yes
25
+
26
+# autodetection_retry sets the job re-check interval in seconds.
27
+# The job is not deleted if check fails.
28
+# Attempts to start the job are made once every autodetection_retry.
29
+# This feature is disabled by default.
30
+# autodetection_retry: 0
31
+
32
+# ----------------------------------------------------------------------
33
+# JOBS (data collection sources)
34
+#
35
+# The default JOBS share the same *name*. JOBS with the same name
36
+# are mutually exclusive. Only one of them will be allowed running at
37
+# any time. This allows autodetection to try several alternatives and
38
+# pick the one that works.
39
+#
40
+# Any number of jobs is supported.
41
+#
42
+# All python.d.plugin JOBS (for all its modules) support a set of
43
+# predefined parameters. These are:
44
+#
45
+# job_name:
46
+# name: myname # the JOB's name as it will appear at the
47
+# # dashboard (by default is the job_name)
48
+# # JOBs sharing a name are mutually exclusive
49
+# update_every: 5 # the JOB's data collection frequency
50
+# priority: 60000 # the JOB's order on the dashboard
51
+# penalty: yes # the JOB's penalty
52
+# autodetection_retry: 0 # the JOB's re-check interval in seconds
53
+#
54
+# Additionally to the above, hpssa also supports the following:
55
+#
56
+# ssacli_path: /usr/sbin/ssacli # The path to the ssacli executable
57
+# use_sudo: True # Whether to use sudo or not
58
+# ----------------------------------------------------------------------
59
+
60
+# ssacli_path: /usr/sbin/ssacli
61
+# use_sudo: True
collectors/python.d.plugin/python.d.conf
+1
@@ -58,6 +58,7 @@ gunicorn_log: no
58
# haproxy: yes
59
# hddtemp: yes
60
# httpcheck: yes
61
+hpssa: no
62
# icecast: yes
63
# ipfs: yes
64
# isc_dhcpd: yes