From 8b4010f030630b8ab6ad55e4aa0f1c95d00531da Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 3 Jul 2026 17:23:34 +0200 Subject: [PATCH] fix(librenms-health): report numeric sensors that breach their limits The plugin derived a sensor's state only from LibreNMS' state_generic_value, which is populated exclusively for discrete state-class sensors. For numeric sensors (temperature, humidity, voltage, power, ...) that column is NULL, so every over- or under-limit reading was silently reported as OK. Mirror LibreNMS' own Sensor::currentStatus() so the check agrees with the severity shown on the LibreNMS device health pages: CRITICAL at or beyond the hard sensor_limit / sensor_limit_low, WARNING at or beyond the soft sensor_limit_warn / sensor_limit_low_warn (inclusive comparison), UNKNOWN for a state sensor without a translation or a numeric sensor without a reading. The decision is extracted into a pure get_sensor_state() helper computed before the row values are turned into display strings. The value column now shows both limit ranges as "low_warn..high_warn/low_crit..high_crit" (header "Val (warn/crit Range)"); an unset range is shown as "-" so the remaining one stays unambiguous, since LibreNMS auto-discovery commonly configures only the critical limits. Add a --test seam plus a unit-test suite (15 cases) covering the warn/crit low and high bands, inclusive boundaries, single-sided limits, the "-" placeholder, state sensors, missing readings, mixed batches and --always-ok, driven by JSON row fixtures without a live database. Also drop three dead HTTP-era DEFAULT_* constants and the unused lib.librenms import. --- CHANGELOG.md | 1 + check-plugins/librenms-health/README.md | 52 ++-- check-plugins/librenms-health/librenms-health | 245 ++++++++++++------ check-plugins/librenms-health/unit-test/run | 178 +++++++++++++ .../librenms-health/unit-test/stdout/all-ok | 36 +++ .../unit-test/stdout/humidity-crit-at-limit | 19 ++ .../unit-test/stdout/humidity-crit-over | 19 ++ .../unit-test/stdout/humidity-crit-under | 19 ++ .../unit-test/stdout/humidity-ok-in-range | 19 ++ .../unit-test/stdout/humidity-warn-at-limit | 19 ++ .../unit-test/stdout/humidity-warn-over | 19 ++ .../unit-test/stdout/humidity-warn-under | 19 ++ .../librenms-health/unit-test/stdout/mixed | 36 +++ .../unit-test/stdout/numeric-high-limits-only | 19 ++ .../unit-test/stdout/numeric-no-reading | 19 ++ .../unit-test/stdout/numeric-only-crit-limits | 19 ++ .../unit-test/stdout/numeric-only-warn-limits | 19 ++ .../unit-test/stdout/state-sensor-warn | 19 ++ 18 files changed, 678 insertions(+), 98 deletions(-) create mode 100755 check-plugins/librenms-health/unit-test/run create mode 100644 check-plugins/librenms-health/unit-test/stdout/all-ok create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-crit-at-limit create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-crit-over create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-crit-under create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-ok-in-range create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-warn-at-limit create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-warn-over create mode 100644 check-plugins/librenms-health/unit-test/stdout/humidity-warn-under create mode 100644 check-plugins/librenms-health/unit-test/stdout/mixed create mode 100644 check-plugins/librenms-health/unit-test/stdout/numeric-high-limits-only create mode 100644 check-plugins/librenms-health/unit-test/stdout/numeric-no-reading create mode 100644 check-plugins/librenms-health/unit-test/stdout/numeric-only-crit-limits create mode 100644 check-plugins/librenms-health/unit-test/stdout/numeric-only-warn-limits create mode 100644 check-plugins/librenms-health/unit-test/stdout/state-sensor-warn diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b59a2563..4cb35e6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Monitoring Plugins: * disk-usage: performance data now carries the effective warning and critical thresholds again, so graphing tools such as Icinga Graphite can draw the warn/crit lines ([#1310](https://github.com/Linuxfabrik/monitoring-plugins/issues/1310)) * journald-query: a relative `--since` value such as `-8h` configured in the Icinga Director now works instead of making the check fail ([#1264](https://github.com/Linuxfabrik/monitoring-plugins/issues/1264)) +* librenms-health: numeric sensors (temperature, humidity, voltage, power, etc.) that breach their configured LibreNMS limits are now reported, matching the severity LibreNMS shows on its device health pages (WARNING at the warning limit, CRITICAL at the critical limit); previously only discrete state sensors could raise an alert, while an over- or under-limit numeric reading was silently reported as OK * lynis: audits now produce a report on Debian, Ubuntu and other distributions that keep the lynis plugins outside `/usr/share` ([#1262](https://github.com/Linuxfabrik/monitoring-plugins/issues/1262)) * lynis: when an audit produces no report, the plugin now shows the underlying lynis error instead of a generic message * redfish-\*: the Redfish API URL is now a mandatory `--url` parameter; the previous placeholder default silently pointed unconfigured checks at `localhost` instead of the target BMC, producing misleading connection errors ([#1306](https://github.com/Linuxfabrik/monitoring-plugins/issues/1306)) diff --git a/check-plugins/librenms-health/README.md b/check-plugins/librenms-health/README.md index 5ba7084f2..4deb48545 100644 --- a/check-plugins/librenms-health/README.md +++ b/check-plugins/librenms-health/README.md @@ -17,7 +17,7 @@ Retrieves hardware sensor information (temperature, humidity, voltage, power, st * Queries the LibreNMS MySQL/MariaDB database directly (the API is too resource-intensive for large-scale environments) * Joins `devices`, `sensors`, `sensors_to_state_indexes`, `state_translations`, `device_groups`, and `locations` tables * For state-class sensors, displays the state description instead of the raw numeric value -* For numeric sensors with defined limits, displays the value together with its low/high range +* For numeric sensors, compares the value against its four configured limits the same way LibreNMS does on its device health pages (warning and critical, low and high) and displays the value together with its low/high range * Supports filtering by device group (`--device-group`, with SQL wildcards), device hostname (`--device-hostname`, repeatable), and device type (`--device-type`, repeatable) * In default (compact) mode, only sensors with alerts are shown; use `--lengthy` to display all sensors with extended details (type, location, sensor class, last update time) @@ -79,38 +79,56 @@ options: ## Usage Examples ```bash -./librenms-health --defaults-file=/var/spool/icinga2/.my.cnf --device-group="%storage%" +./librenms-health --defaults-file=/var/spool/icinga2/.my.cnf ``` Output: ```text -There are 4 alerts. Checked 113 sensors. - -Hostname ! SysName ! Sensor ! Val (Range) ! State --------------+----------+--------------------------+--------------------+----------- -192.0.2.11 ! synoRZ02 ! Disk 17 MZILT1T9HAJQ/007 ! NotInitialized ! [WARNING] -192.0.2.11 ! synoRZ02 ! Disk 18 MZILT1T9HAJQ/007 ! NotInitialized ! [WARNING] -storinator02 ! synoRZ04 ! Upgrade Availability ! Available ! [WARNING] -storinator02 ! synoRZ04 ! Disk 5 WD4000FYYZ-01UL1B3 ! NotInitialized ! [WARNING] +There are 2 alerts. Checked 4 sensors. + +Hostname ! SysName ! Sensor ! Val (warn/crit Range) ! State +-------------+----------+--------------------------+------------------------------+----------- +192.0.2.11 ! pdu-a1 ! lgpEnvSupplyAirHumidity ! 71.7 (35.0..65.0/30.0..70.0) ! [CRITICAL] +192.0.2.11 ! pdu-a1 ! lgpEnvReturnAirHumidity ! 82.0 (-/20.0..70.0) ! [CRITICAL] +storinator02 ! synoRZ04 ! Disk 17 MZILT1T9HAJQ/007 ! NotInitialized ! [WARNING] ``` With `--lengthy`: ```text -There are 4 alerts. Checked 113 sensors. +There are 2 alerts. Checked 4 sensors. + +Hostname ! SysName ! Type ! Location ! Sensor ! Class ! Changed ! Val (warn/crit Range) ! State +-------------+----------------+---------+-------------+--------------------------+----------+---------+------------------------------+----------- +192.0.2.11 ! pdu-a1 ! power ! DC1 Rack A1 ! lgpEnvSupplyAirHumidity ! humidity ! 3m 22s ! 71.7 (35.0..65.0/30.0..70.0) ! [CRITICAL] +192.0.2.11 ! pdu-a1 ! power ! DC1 Rack A1 ! lgpEnvReturnAirHumidity ! humidity ! 23m 27s ! 82.0 (-/20.0..70.0) ! [CRITICAL] +storinator02 ! synoRZ04 ! storage ! DC1 Rack B3 ! Disk 17 MZILT1T9HAJQ/007 ! state ! 2h 15m ! NotInitialized ! [WARNING] +sw01 ! core-switch-01 ! network ! DC1 Rack A2 ! PSU 1 Voltage ! voltage ! 5m 1s ! 12.0 (11.8..12.6/11.4..13.0) ! [OK] +``` + +The value column shows each sensor's current reading followed by its two limit ranges, the warning range first and the critical range second: -Hostname ! SysName ! Type ! Location ! Sensor ! Class ! Changed ! Val (Range) ! State --------------+----------+---------+----------+--------------------------+-------------+---------+--------------------+----------- -192.0.2.11 ! synoRZ02 ! storage ! DC1 ! Disk 17 MZILT1T9HAJQ/007 ! state ! 2h 15m ! NotInitialized ! [WARNING] -... +```text +71.7 (35.0..65.0/30.0..70.0) + `----------'`----------' + warn range crit range + low..high low..high ``` +* A range that has a limit on only one side is left open on the other, for example `31.0 (..30.0/..35.0)` for a sensor with high limits but no low limits. +* A range that is not configured at all is shown as `-`, for example `82.0 (-/20.0..70.0)` for a sensor that has critical limits but no warning limits. This is common: LibreNMS auto-discovery sets only the critical limits for most sensors and leaves the warning limits empty, so a lot of sensors report only a critical range and can therefore only ever reach CRITICAL, never WARNING. +* Discrete state sensors have no numeric limits and show only their state description. + +Warning (and critical) limits can be set per sensor in LibreNMS: open the device, go to its settings (the gear icon), and edit the values on the **Health** tab. Once a warning limit is filled in, that sensor starts reporting WARNING before it reaches its critical limit, and its warning range appears here in place of the `-`. + ## States -* OK if all sensor values are within their LibreNMS-configured thresholds. -* WARN, CRIT, or UNKNOWN based on the sensor's `state_generic_value` in LibreNMS (mirrors the alert state from LibreNMS). +* OK if all sensors are within their LibreNMS-configured limits. +* Numeric sensors (temperature, humidity, voltage, power, etc.) report CRIT when the value is at or beyond its critical low/high limit, and WARN when it is at or beyond its warning low/high limit. +* Discrete state sensors (fan, power supply, disk state, etc.) report the severity LibreNMS assigned to the current state: WARN, CRIT or UNKNOWN. +* A sensor without a current reading is UNKNOWN. * Sensors with alerting disabled in LibreNMS are excluded and do not affect the check state. * `--always-ok` suppresses all alerts and always returns OK. diff --git a/check-plugins/librenms-health/librenms-health b/check-plugins/librenms-health/librenms-health index 739f975a5..f3368cf3a 100755 --- a/check-plugins/librenms-health/librenms-health +++ b/check-plugins/librenms-health/librenms-health @@ -11,19 +11,20 @@ """See the check's README for more details.""" import argparse +import json import sys import lib.args import lib.base import lib.db_mysql import lib.human -import lib.librenms +import lib.lftest import lib.time import lib.txt -from lib.globals import STATE_OK, STATE_UNKNOWN +from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2026050801' +__version__ = '2026070301' DESCRIPTION = """Retrieves hardware sensor information (temperature, humidity, voltage, power, etc.) for each device from a LibreNMS instance. Alerts when sensor values exceed their @@ -33,11 +34,8 @@ Supports extended reporting via --lengthy.""" DEFAULT_DEFAULTS_FILE = '/var/spool/icinga2/.my.cnf' DEFAULT_DEFAULTS_GROUP = 'client' -DEFAULT_INSECURE = False DEFAULT_LENGTHY = False -DEFAULT_NO_PROXY = False DEFAULT_TIMEOUT = 3 -DEFAULT_URL = 'http://localhost' def parse_args(): @@ -93,7 +91,8 @@ def parse_args(): help='Filter by LibreNMS device type. Can be specified multiple times.', dest='DEVICE_TYPE', action='append', - choices=[ # taken from the librenms source file resources/definitions/config_definitions.json + # choices from the librenms source resources/definitions/config_definitions.json + choices=[ 'appliance', 'collaboration', 'environment', @@ -118,6 +117,13 @@ def parse_args(): default=DEFAULT_LENGTHY, ) + parser.add_argument( + '--test', + help=lib.args.help('--test'), + dest='TEST', + type=lib.args.csv, + ) + parser.add_argument( '--timeout', help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)', @@ -130,6 +136,75 @@ def parse_args(): return args +def get_sensor_state(sensor): + """Determine the Nagios state for a single LibreNMS sensor row. + + This mirrors LibreNMS' own `Sensor::currentStatus()`, so the plugin agrees + with the severity LibreNMS shows on its device health and sensors pages. + + * Discrete state-class sensors (fan ok/fail, psu present/absent, ...) carry + a pre-computed severity in `state_generic_value`, which shares the Nagios + numbering (0 = ok, 1 = warn, 2 = crit, 3 = unknown). A state sensor + without a matching translation is UNKNOWN, as in LibreNMS. + * Numeric sensors (temperature, humidity, voltage, power, ...) are compared + against their four configured limits: CRITICAL when at or beyond the + hard `sensor_limit` / `sensor_limit_low`, WARNING when at or beyond the + softer `sensor_limit_warn` / `sensor_limit_low_warn`. Comparisons are + inclusive, matching LibreNMS. A sensor without a current reading is + UNKNOWN. + """ + if sensor['sensor_class'] == 'state': + generic = sensor['state_generic_value'] + return int(generic) if generic is not None else STATE_UNKNOWN + + current = sensor['sensor_current'] + if current is None: + return STATE_UNKNOWN + current = float(current) + + limit = sensor['sensor_limit'] + limit_low = sensor['sensor_limit_low'] + over_crit = limit is not None and current >= float(limit) + under_crit = limit_low is not None and current <= float(limit_low) + if over_crit or under_crit: + return STATE_CRIT + + limit_warn = sensor['sensor_limit_warn'] + limit_low_warn = sensor['sensor_limit_low_warn'] + over_warn = limit_warn is not None and current >= float(limit_warn) + under_warn = limit_low_warn is not None and current <= float(limit_low_warn) + if over_warn or under_warn: + return STATE_WARN + + return STATE_OK + + +def format_sensor_range(sensor): + """Build the `warn/crit` range annotation shown next to a sensor value. + + Renders the warning range and the critical range side by side as + `low_warn..high_warn/low_crit..high_crit`, matching the + `Val (warn/crit Range)` column header. The warning range is always on the + left and the critical range on the right; a range that is not configured at + all is shown as `-` so the remaining one stays unambiguous (LibreNMS' + auto-discovery commonly sets only the critical limits). A range with a + limit on only one side is left open on the other (e.g. `..70.0`). Returns + an empty string when the sensor has no numeric limits at all (e.g. discrete + state sensors). + """ + + def _range(low, high): + if low is None and high is None: + return '' + return f'{"" if low is None else low}..{"" if high is None else high}' + + warn = _range(sensor['sensor_limit_low_warn'], sensor['sensor_limit_warn']) + crit = _range(sensor['sensor_limit_low'], sensor['sensor_limit']) + if not warn and not crit: + return '' + return f'{warn or "-"}/{crit or "-"}' + + def main(): """The main function. This is where the magic happens.""" @@ -139,88 +214,86 @@ def main(): except SystemExit: sys.exit(STATE_UNKNOWN) - mysql_connection = { - 'defaults_file': args.DEFAULTS_FILE, - 'defaults_group': args.DEFAULTS_GROUP, - 'timeout': args.TIMEOUT, - } - conn = lib.base.coe(lib.db_mysql.connect(mysql_connection)) - lib.base.coe(lib.db_mysql.check_privileges(conn)) + # fetch data + if args.TEST is None: + mysql_connection = { + 'defaults_file': args.DEFAULTS_FILE, + 'defaults_group': args.DEFAULTS_GROUP, + 'timeout': args.TIMEOUT, + } + conn = lib.base.coe(lib.db_mysql.connect(mysql_connection)) + lib.base.coe(lib.db_mysql.check_privileges(conn)) + sql = """ + SELECT + d.hostname, + d.sysName, + d.sysDescr, + d.type, + l.location, + s.sensor_descr, + s.sensor_current, + s.sensor_limit, + s.sensor_limit_warn, + s.sensor_limit_low, + s.sensor_limit_low_warn, + s.lastupdate, + s.sensor_class, + st.state_descr, + st.state_generic_value + FROM + devices AS d + LEFT JOIN + locations AS l ON d.location_id = l.id + LEFT JOIN + device_group_device AS dgd ON d.device_id = dgd.device_id + LEFT JOIN + device_groups AS dg ON dgd.device_group_id = dg.id + LEFT JOIN + sensors AS s ON d.device_id = s.device_id + LEFT JOIN + sensors_to_state_indexes AS stsi ON s.sensor_id = stsi.sensor_id + LEFT JOIN + state_translations AS st ON stsi.state_index_id = st.state_index_id + AND st.state_value = s.sensor_current + WHERE + disable_notify = 0 + AND s.sensor_alert = 1 + """ + data = [] + if args.DEVICE_GROUP: + sql += ' AND dg.name LIKE %s ' + data.append(args.DEVICE_GROUP) + if args.DEVICE_HOSTNAME: + sql += ( + f' AND d.hostname IN ({", ".join("%s" for _ in args.DEVICE_HOSTNAME)}) ' + ) + data += args.DEVICE_HOSTNAME + if args.DEVICE_TYPE: + sql += f' AND d.type IN ({", ".join("%s" for _ in args.DEVICE_TYPE)}) ' + data += args.DEVICE_TYPE + sql += ' ORDER BY d.hostname, s.sensor_class' + sensors = lib.base.coe(lib.db_mysql.select(conn, sql, data)) + lib.db_mysql.close(conn) + else: + # do not query the database, put in test data (a JSON array of rows, + # shaped exactly like the SELECT above returns them) + stdout, _, _ = lib.lftest.test(args.TEST) + sensors = json.loads(stdout) # init some vars state = STATE_OK perfdata = '' alert_count = 0 - sensors_count = 0 - - # fetch data - sql = """ - SELECT - d.hostname, - d.sysName, - d.sysDescr, - d.type, - l.location, - s.sensor_descr, - s.sensor_current, - s.sensor_limit, - s.sensor_limit_low, - s.lastupdate, - s.sensor_class, - st.state_descr, - st.state_generic_value - FROM - devices AS d - LEFT JOIN - locations AS l ON d.location_id = l.id - LEFT JOIN - device_group_device AS dgd ON d.device_id = dgd.device_id - LEFT JOIN - device_groups AS dg ON dgd.device_group_id = dg.id - LEFT JOIN - sensors AS s ON d.device_id = s.device_id - LEFT JOIN - sensors_to_state_indexes AS stsi ON s.sensor_id = stsi.sensor_id - LEFT JOIN - state_translations AS st ON stsi.state_index_id = st.state_index_id - AND st.state_value = s.sensor_current - WHERE - disable_notify = 0 - AND s.sensor_alert = 1 - """ - data = [] - if args.DEVICE_GROUP: - sql += ' AND dg.name LIKE %s ' - data.append(args.DEVICE_GROUP) - if args.DEVICE_HOSTNAME: - sql += f' AND d.hostname IN ({", ".join("%s" for _ in args.DEVICE_HOSTNAME)}) ' - data += args.DEVICE_HOSTNAME - if args.DEVICE_TYPE: - sql += f' AND d.type IN ({", ".join("%s" for _ in args.DEVICE_TYPE)}) ' - data += args.DEVICE_TYPE - sql += ' ORDER BY d.hostname, s.sensor_class' - sensors = lib.base.coe(lib.db_mysql.select(conn, sql, data)) - lib.db_mysql.close(conn) sensors_count = len(sensors) - # enrich and analyse data + # analyze data for i, sensor in enumerate(sensors): if not sensor['sysName']: sensors[i]['sysName'] = sensor['sysDescr'] - if sensor['sensor_class'] == 'state': - sensors[i]['sensor_current'] = sensor['state_descr'] - if all([sensor['sensor_limit_low'], sensor['sensor_limit']]): - sensor['sensor_current'] = ( - f'{sensor["sensor_current"]} ({sensor["sensor_limit_low"]}..{sensor["sensor_limit"]})' - ) - if sensor['lastupdate']: - delta = lib.time.now(as_type='datetime') - sensor['lastupdate'] - sensor['lastupdate'] = lib.human.seconds2human(delta.total_seconds()) - local_state = sensor[ - 'state_generic_value' - ] # 0 = ok, 1 = warn, 2 = crit, 3 = unknown - if not local_state: # including "None" - local_state = STATE_OK + + # determine the state from the raw values before the display strings + # below overwrite sensor_current + local_state = get_sensor_state(sensor) if local_state != STATE_OK: alert_count += 1 sensors[i]['state'] = lib.base.state2str( @@ -229,6 +302,16 @@ def main(): ) state = lib.base.get_worst(local_state, state) + # turn raw values into human-readable display strings + if sensor['sensor_class'] == 'state': + sensors[i]['sensor_current'] = sensor['state_descr'] + sensor_range = format_sensor_range(sensor) + if sensor_range: + sensor['sensor_current'] = f'{sensor["sensor_current"]} ({sensor_range})' + if sensor['lastupdate']: + delta = lib.time.now(as_type='datetime') - sensor['lastupdate'] + sensor['lastupdate'] = lib.human.seconds2human(delta.total_seconds()) + # filter data if compact layout is choosen (just get everything that is not ok) if not args.LENGTHY: # brief data @@ -271,7 +354,7 @@ def main(): 'Hostname', 'SysName', 'Sensor', - 'Val (Range)', + 'Val (warn/crit Range)', 'State', ], ) @@ -297,7 +380,7 @@ def main(): 'Sensor', 'Class', 'Changed', - 'Val (Range)', + 'Val (warn/crit Range)', 'State', ], ) diff --git a/check-plugins/librenms-health/unit-test/run b/check-plugins/librenms-health/unit-test/run new file mode 100755 index 000000000..b17821759 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/run @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md + +"""Unit tests for the librenms-health check plugin. + +The fixtures in `stdout/` are JSON arrays of sensor rows, shaped exactly +like the plugin's SELECT against the LibreNMS database returns them. In +test mode (`--test`) the plugin skips the database and loads these rows +instead, so the full state machine can be exercised without a live +MySQL/MariaDB instance. + +The plugin mirrors LibreNMS' own `Sensor::currentStatus()`: + +* discrete state-class sensors carry a pre-computed `state_generic_value` + (0 = ok, 1 = warn, 2 = crit, 3 = unknown) +* numeric sensors are compared against their four limits, inclusively: + CRITICAL at/beyond `sensor_limit` / `sensor_limit_low`, WARNING at/beyond + `sensor_limit_warn` / `sensor_limit_low_warn` + +The humidity fixtures share one limit set (low crit 30, low warn 35, high +warn 65, high crit 70) and only vary the current reading. +""" + +import sys + +sys.path.insert(0, '..') + +import unittest + +import lib.lftest +from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN + +TESTS = [ + { + # Numeric sensor above its high critical limit (71.7 >= 70). No + # state_translations row exists for numeric sensors, so the plugin + # must evaluate the limits itself. This is the exact scenario that + # used to be silently reported as OK. + 'id': 'crit-over-high-limit', + 'test': 'stdout/humidity-crit-over,,0', + 'assert-retc': STATE_CRIT, + 'assert-in': [ + 'lgpEnvSupplyAirHumidity', + '71.7 (35.0..65.0/30.0..70.0)', + '[CRITICAL]', + ], + }, + { + # Numeric sensor below its low critical limit (25.0 <= 30). + 'id': 'crit-under-low-limit', + 'test': 'stdout/humidity-crit-under,,0', + 'assert-retc': STATE_CRIT, + 'assert-in': ['25.0 (35.0..65.0/30.0..70.0)', '[CRITICAL]'], + }, + { + # Boundary: current equals the high critical limit (70.0). LibreNMS' + # currentStatus() compares inclusively, so this is CRITICAL. + 'id': 'crit-at-high-limit-boundary', + 'test': 'stdout/humidity-crit-at-limit,,0', + 'assert-retc': STATE_CRIT, + 'assert-in': ['[CRITICAL]'], + }, + { + # Numeric sensor in the warning band above the high warn limit + # (67.0 >= 65) but below the critical limit (67.0 < 70). + 'id': 'warn-over-high-warn-limit', + 'test': 'stdout/humidity-warn-over,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['67.0 (35.0..65.0/30.0..70.0)', '[WARNING]'], + }, + { + # Numeric sensor in the warning band below the low warn limit + # (33.0 <= 35) but above the critical limit (33.0 > 30). + 'id': 'warn-under-low-warn-limit', + 'test': 'stdout/humidity-warn-under,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['33.0 (35.0..65.0/30.0..70.0)', '[WARNING]'], + }, + { + # Boundary: current equals the high warn limit (65.0). Inclusive + # comparison makes this WARNING, not OK. + 'id': 'warn-at-high-warn-boundary', + 'test': 'stdout/humidity-warn-at-limit,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['[WARNING]'], + }, + { + # Numeric sensor comfortably inside every limit: OK. + 'id': 'ok-in-range', + 'test': 'stdout/humidity-ok-in-range,,0', + 'assert-retc': STATE_OK, + 'assert-in': ['Everything is ok.', 'Checked 1 sensor.'], + }, + { + # Only the critical limits are set (both warn columns NULL) and the + # value breaches the high critical limit. The warn checks must be + # skipped, not crash, and the empty warn range shows as `-`. + 'id': 'crit-only-critical-limits-set', + 'test': 'stdout/numeric-only-crit-limits,,0', + 'assert-retc': STATE_CRIT, + 'assert-in': ['80.0 (-/30.0..70.0)', '[CRITICAL]'], + }, + { + # Only the warn limits are set (both critical columns NULL) and the + # value breaches the high warn limit. The empty crit range shows as `-`. + 'id': 'warn-only-warn-limits-set', + 'test': 'stdout/numeric-only-warn-limits,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['67.0 (35.0..65.0/-)', '[WARNING]'], + }, + { + # Only high limits are configured (both low columns NULL): the range + # annotation opens with `..` to signal no lower bound. + 'id': 'warn-high-limits-only-range', + 'test': 'stdout/numeric-high-limits-only,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['31.0 (..30.0/..35.0)', '[WARNING]'], + }, + { + # A numeric sensor without a current reading is UNKNOWN, as in + # LibreNMS' currentStatus(). + 'id': 'unknown-no-reading', + 'test': 'stdout/numeric-no-reading,,0', + 'assert-retc': STATE_UNKNOWN, + 'assert-in': ['[UNKNOWN]'], + }, + { + # Discrete state-class sensor: severity comes straight from + # state_generic_value (1 = warn), and the state description is shown + # instead of the raw numeric value. + 'id': 'warn-state-sensor', + 'test': 'stdout/state-sensor-warn,,0', + 'assert-retc': STATE_WARN, + 'assert-in': ['NotInitialized', '[WARNING]'], + }, + { + # Mixed batch: a critical numeric breach and a warning state sensor. + # The overall state is the worst (CRITICAL) and both count as alerts. + 'id': 'crit-mixed-worst-wins', + 'test': 'stdout/mixed,,0', + 'assert-retc': STATE_CRIT, + 'assert-in': ['There are 2 alerts.', 'Checked 2 sensors.', '[CRITICAL]'], + }, + { + # Everything within limits: OK, with the verdict printed first. + 'id': 'ok-all-within-limits', + 'test': 'stdout/all-ok,,0', + 'assert-retc': STATE_OK, + 'assert-in': ['Everything is ok.', 'Checked 2 sensors.'], + }, + { + # --always-ok masks the critical breach in the return code, but the + # offending sensor is still shown as [CRITICAL] in the output. + 'id': 'ok-always-ok-masks-crit', + 'test': 'stdout/humidity-crit-over,,0', + 'params': '--always-ok', + 'assert-retc': STATE_OK, + 'assert-regex': r'71\.7 \(35\.0\.\.65\.0/30\.0\.\.70\.0\).*\[CRITICAL\]', + }, +] + + +class TestCheck(unittest.TestCase): + check = '../librenms-health' + + +lib.lftest.attach_tests(TestCheck, TESTS) + + +if __name__ == '__main__': + unittest.main() diff --git a/check-plugins/librenms-health/unit-test/stdout/all-ok b/check-plugins/librenms-health/unit-test/stdout/all-ok new file mode 100644 index 000000000..f65bb77f4 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/all-ok @@ -0,0 +1,36 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 50.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + }, + { + "hostname": "sw01", + "sysName": "core-switch-01", + "sysDescr": "Synology", + "type": "storage", + "location": "DC1", + "sensor_descr": "PSU 1 Status", + "sensor_current": 1, + "sensor_limit": null, + "sensor_limit_warn": null, + "sensor_limit_low": null, + "sensor_limit_low_warn": null, + "lastupdate": null, + "sensor_class": "state", + "state_descr": "on", + "state_generic_value": 0 + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-crit-at-limit b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-at-limit new file mode 100644 index 000000000..54056b282 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-at-limit @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 70.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-crit-over b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-over new file mode 100644 index 000000000..4df7a655b --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-over @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 71.7, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-crit-under b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-under new file mode 100644 index 000000000..eccf9da12 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-crit-under @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 25.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-ok-in-range b/check-plugins/librenms-health/unit-test/stdout/humidity-ok-in-range new file mode 100644 index 000000000..19997e235 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-ok-in-range @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 50.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-warn-at-limit b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-at-limit new file mode 100644 index 000000000..676c278a7 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-at-limit @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 65.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-warn-over b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-over new file mode 100644 index 000000000..9d9f2496d --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-over @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 67.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/humidity-warn-under b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-under new file mode 100644 index 000000000..69a0ed1c4 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/humidity-warn-under @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 33.0, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/mixed b/check-plugins/librenms-health/unit-test/stdout/mixed new file mode 100644 index 000000000..c024d5a73 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/mixed @@ -0,0 +1,36 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 71.7, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + }, + { + "hostname": "storinator02", + "sysName": "synoRZ04", + "sysDescr": "Synology", + "type": "storage", + "location": "DC1", + "sensor_descr": "Disk 17 MZILT1T9HAJQ/007", + "sensor_current": 3, + "sensor_limit": null, + "sensor_limit_warn": null, + "sensor_limit_low": null, + "sensor_limit_low_warn": null, + "lastupdate": null, + "sensor_class": "state", + "state_descr": "NotInitialized", + "state_generic_value": 1 + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/numeric-high-limits-only b/check-plugins/librenms-health/unit-test/stdout/numeric-high-limits-only new file mode 100644 index 000000000..7928176f2 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/numeric-high-limits-only @@ -0,0 +1,19 @@ +[ + { + "hostname": "sw01", + "sysName": "core-switch-01", + "sysDescr": "Switch", + "type": "network", + "location": "DC1", + "sensor_descr": "Temperature CPU", + "sensor_current": 31.0, + "sensor_limit": 35.0, + "sensor_limit_warn": 30.0, + "sensor_limit_low": null, + "sensor_limit_low_warn": null, + "lastupdate": null, + "sensor_class": "temperature", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/numeric-no-reading b/check-plugins/librenms-health/unit-test/stdout/numeric-no-reading new file mode 100644 index 000000000..98e9d7123 --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/numeric-no-reading @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": null, + "sensor_limit": 70.0, + "sensor_limit_warn": 65.0, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/numeric-only-crit-limits b/check-plugins/librenms-health/unit-test/stdout/numeric-only-crit-limits new file mode 100644 index 000000000..89154d72b --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/numeric-only-crit-limits @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 80.0, + "sensor_limit": 70.0, + "sensor_limit_warn": null, + "sensor_limit_low": 30.0, + "sensor_limit_low_warn": null, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/numeric-only-warn-limits b/check-plugins/librenms-health/unit-test/stdout/numeric-only-warn-limits new file mode 100644 index 000000000..0aadba06f --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/numeric-only-warn-limits @@ -0,0 +1,19 @@ +[ + { + "hostname": "c0401", + "sysName": "ROS C0401", + "sysDescr": "Liebert environmental unit", + "type": "power", + "location": "DC1", + "sensor_descr": "lgpEnvSupplyAirHumidity", + "sensor_current": 67.0, + "sensor_limit": null, + "sensor_limit_warn": 65.0, + "sensor_limit_low": null, + "sensor_limit_low_warn": 35.0, + "lastupdate": null, + "sensor_class": "humidity", + "state_descr": null, + "state_generic_value": null + } +] diff --git a/check-plugins/librenms-health/unit-test/stdout/state-sensor-warn b/check-plugins/librenms-health/unit-test/stdout/state-sensor-warn new file mode 100644 index 000000000..50fae7abc --- /dev/null +++ b/check-plugins/librenms-health/unit-test/stdout/state-sensor-warn @@ -0,0 +1,19 @@ +[ + { + "hostname": "storinator02", + "sysName": "synoRZ04", + "sysDescr": "Synology", + "type": "storage", + "location": "DC1", + "sensor_descr": "Disk 17 MZILT1T9HAJQ/007", + "sensor_current": 3, + "sensor_limit": null, + "sensor_limit_warn": null, + "sensor_limit_low": null, + "sensor_limit_low_warn": null, + "lastupdate": null, + "sensor_class": "state", + "state_descr": "NotInitialized", + "state_generic_value": 1 + } +]