A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.
A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.
- Synology: system, power and fan status and temperature; per disk: status,
health, temperature and bad sectors; per volume: RAID state (scrubbing,
parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
space and size; CPU and system temperature, free memory and fans.
From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).
Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ def _device(name, *, polled=timedelta(seconds=30), error=None):
|
||||
community="s3cret", v3_user="", v3_auth_key="authkey", v3_priv_key="privkey",
|
||||
presets=[], interfaces="", custom=None, host_id=None,
|
||||
last_polled_at=NOW - polled if polled else None, last_ok_at=NOW - timedelta(hours=1),
|
||||
last_error=error, missing=[])
|
||||
last_error=error, missing=[], tables={}, texts={})
|
||||
|
||||
|
||||
def test_only_a_device_whose_poll_failed_is_an_overview_finding():
|
||||
|
||||
@@ -65,7 +65,8 @@ def test_one_get_carries_every_oid_and_answers_come_back_by_key(monkeypatch):
|
||||
[("1.1", "uptime"), ("1.2", "fan"), ("1.3", "label")])
|
||||
assert len(calls) == 1 and calls[0][1] == ["1.1", "1.2", "1.3"] and calls[0][2] == {"lookupMib": False}
|
||||
assert calls[0][0] == ("community", "public", 1)
|
||||
assert result.readings == {"uptime": 4200.0} and result.missing == ["fan", "label"] and result.error is None
|
||||
assert result.readings == {"uptime": 4200.0} and result.missing == ["fan"] and result.error is None
|
||||
assert result.texts == {"label": "eth0"} # a word, for a state reading
|
||||
|
||||
|
||||
def test_v1_drops_the_oid_it_does_not_know_and_asks_again(monkeypatch):
|
||||
@@ -151,3 +152,42 @@ def test_close_engine_never_raises():
|
||||
|
||||
poller._close_engine(Engine())
|
||||
poller._close_engine(object())
|
||||
|
||||
|
||||
def _walk(monkeypatch, columns, error=None):
|
||||
"""Walk tables against a device whose columns are {column oid: {index: value}}."""
|
||||
walked = []
|
||||
|
||||
async def walk(engine, auth, transport, ctx, obj, **opts):
|
||||
walked.append(obj)
|
||||
if error:
|
||||
yield error, None, None, []
|
||||
return
|
||||
for index, value in columns.get(obj, {}).items():
|
||||
yield None, None, None, [(f"{obj}.{index}", value)]
|
||||
|
||||
monkeypatch.setattr(poller, "_pysnmp_available", lambda: True)
|
||||
api = _fake_api(lambda oids: (None, None, None, []), [])
|
||||
api.walk = walk
|
||||
monkeypatch.setattr(poller, "_api", lambda: api)
|
||||
return walked
|
||||
|
||||
|
||||
def test_a_table_walk_finds_each_row_its_label_and_the_columns_it_answered(monkeypatch):
|
||||
from plugins.snmp.readings import Column, Table
|
||||
disks = Table("t", "disk", "9.2", "Disk", (Column("9.5", "status", "status"), Column("9.6", "temp", "temp")),
|
||||
rows_like=r"Disk \d+")
|
||||
walked = _walk(monkeypatch, {"9.2": {"1": "Disk 1", "0": "Disk 0", "7": "Cache"},
|
||||
"9.5": {"0": 1, "1": 1, "7": 1}, "9.6": {"1": 38}})
|
||||
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"), [disks]))
|
||||
assert walked == ["9.2", "9.5", "9.6"] and scan.error is None
|
||||
assert scan.rows == {"t": [{"index": "0", "label": "Disk 0", "columns": ["status"]},
|
||||
{"index": "1", "label": "Disk 1", "columns": ["status", "temp"]}]}
|
||||
|
||||
|
||||
def test_a_table_walk_with_no_reply_says_what_to_check(monkeypatch):
|
||||
from plugins.snmp.readings import Column, Table
|
||||
_walk(monkeypatch, {}, error="No SNMP response received before timeout")
|
||||
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"),
|
||||
[Table("t", "", "", "Fan", (Column("9.2", "fan", "Fan speed"),))]))
|
||||
assert scan.rows == {} and scan.error.startswith("No reply from 10.0.0.2:161")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""What an SNMP device is asked for and how each answer reads (milestone 491)."""
|
||||
import pytest
|
||||
|
||||
from plugins.snmp.readings import (device_readings, format_value, interface_readings, parse_custom,
|
||||
parse_interfaces, stored_unit)
|
||||
from plugins.snmp.readings import (TABLES, Column, Reading, Table, device_readings, format_value,
|
||||
interface_readings, parse_custom, parse_interfaces, reading_text, state_of,
|
||||
stored_unit, table_readings, tables_for)
|
||||
|
||||
|
||||
def test_interface_numbers_read_as_a_list():
|
||||
@@ -64,6 +65,72 @@ def test_presets_come_in_order_and_names_may_not_repeat():
|
||||
("if_status", 99, "code 99"),
|
||||
("number", 41.25, "41.25"),
|
||||
("number", None, "—"),
|
||||
("state", 2, "Warning"),
|
||||
("rpm", 1450, "1,450 rpm"),
|
||||
])
|
||||
def test_a_value_reads_in_its_unit(unit, value, text):
|
||||
assert format_value(unit, value) == text
|
||||
|
||||
|
||||
_DISKS = Table("d", "disk", "9.2", "Disk", (Column("9.5", "status", "status", "state", states="words"),
|
||||
Column("9.6", "temp", "temperature", "celsius")))
|
||||
|
||||
|
||||
def test_a_table_row_is_keyed_by_its_index_and_named_by_its_label():
|
||||
got = table_readings(_DISKS, [{"index": "0", "label": "Disk 1", "columns": ["status", "temp"]},
|
||||
{"index": "1", "label": "2", "columns": ["temp"]}])
|
||||
assert [(r.oid, r.key, r.name, r.unit) for r in got] == [
|
||||
("9.5.0", "disk0_status", "Disk 1 status", "state"),
|
||||
("9.6.0", "disk0_temp", "Disk 1 temperature", "celsius"),
|
||||
("9.6.1", "disk1_temp", "Disk 2 temperature", "celsius")] # a bare bay number gets its noun
|
||||
assert got[0].states == "words"
|
||||
|
||||
|
||||
def test_a_table_without_labels_is_named_by_its_columns():
|
||||
fans = Table("f", "", "", "Fan", (Column("9.2", "fan", "Fan speed", "rpm"),))
|
||||
[one] = table_readings(fans, [{"index": "1", "label": "", "columns": ["fan"]}])
|
||||
assert (one.key, one.name) == ("fan", "Fan speed")
|
||||
two = table_readings(fans, [{"index": "1", "label": "", "columns": ["fan"]},
|
||||
{"index": "2", "label": "", "columns": ["fan"]}])
|
||||
assert [(r.key, r.name) for r in two] == [("fan1", "Fan speed (fan 1)"), ("fan2", "Fan speed (fan 2)")]
|
||||
|
||||
|
||||
def test_rows_come_after_the_presets_only_for_ticked_presets_and_a_custom_name_wins():
|
||||
rows = {"synology_volumes": [{"index": "0", "label": "Volume 1", "columns": ["status", "free"]}],
|
||||
"asustor_fans": [{"index": "1", "label": "", "columns": ["fan"]}]}
|
||||
keys = [r.key for r in device_readings(["system", "synology"], [], "1.3.6.1.9.9 raid0_free bytes", "2c", rows)]
|
||||
assert keys[0] == "uptime" and "system_status" in keys and "fan" not in keys
|
||||
assert keys.index("raid0_status") > keys.index("cpu_fan") and keys.count("raid0_free") == 1
|
||||
assert keys[-1] == "raid0_free" # the custom line, not the row
|
||||
assert [t.id for t in tables_for(["asustor", "linux"])] == [
|
||||
"ucd_diskio", "asustor_disks", "asustor_volumes", "asustor_temperatures", "asustor_memory", "asustor_fans"]
|
||||
|
||||
|
||||
def test_only_physical_disks_are_read_for_disk_load():
|
||||
[diskio] = TABLES["linux"]
|
||||
assert diskio.keeps("sda") and diskio.keeps("nvme0n1") and diskio.keeps("sata2")
|
||||
assert not diskio.keeps("sda1") and not diskio.keeps("loop0") and not diskio.keeps("md2")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("states, raw, want", [
|
||||
("synology_raid", 13, ("Data scrubbing", 1)),
|
||||
("synology_raid", 11.0, ("Degraded", 2)),
|
||||
("synology_raid", 1, ("Normal", 0)),
|
||||
("synology_disk", 5, ("Crashed", 3)),
|
||||
("synology_raid", 99, ("code 99", None)),
|
||||
("words", " Normal ", ("Normal", 0)),
|
||||
("words", "Healthy", ("Healthy", 0)),
|
||||
("words", "Unhealthy", ("Unhealthy", 3)),
|
||||
("words", "Degraded", ("Degraded", 2)),
|
||||
("words", "Resyncing", ("Resyncing", 1)),
|
||||
("words", "Broken", ("Broken", 3)),
|
||||
("words", "Frobnicated", ("Frobnicated", None)),
|
||||
])
|
||||
def test_a_vendor_state_reads_as_its_word_and_a_level(states, raw, want):
|
||||
assert state_of(Reading("1.1", "s", "S", "state", states=states), raw) == want
|
||||
|
||||
|
||||
def test_a_state_shows_the_device_word_and_falls_back_to_its_level():
|
||||
r = Reading("1.1", "raid0_status", "Volume 1 status", "state", states="synology_raid")
|
||||
assert reading_text(r, 1.0, {"raid0_status": "Data scrubbing"}) == "Data scrubbing"
|
||||
assert reading_text(r, 1.0, {}) == "Busy" and reading_text(r, None, None) == "—"
|
||||
|
||||
@@ -87,6 +87,21 @@ def test_test_uses_what_is_typed_and_falls_back_to_the_stored_secret():
|
||||
assert form_credentials(_form(), None)[2].community == "public"
|
||||
|
||||
|
||||
def test_a_nas_preset_alone_is_a_choice_though_its_rows_come_with_the_first_poll():
|
||||
assert parse_form(_form(presets=["asustor"]))["presets"] == ["asustor"]
|
||||
with pytest.raises(FormError, match="Choose at least one preset"):
|
||||
parse_form(_form(presets=[]))
|
||||
|
||||
|
||||
def test_a_device_is_asked_for_its_stored_rows_or_the_ones_just_found():
|
||||
row = NS(presets=["synology"], interfaces="", custom="", version="2c",
|
||||
tables={"synology_volumes": [{"index": "0", "label": "Volume 1", "columns": ["status"]}]})
|
||||
assert "raid0_status" in [r.key for r in readings_for(row)]
|
||||
found = {"synology_disks": [{"index": "0", "label": "Disk 1", "columns": ["temp"]}]}
|
||||
keys = [r.key for r in readings_for(row, found)]
|
||||
assert "disk0_temp" in keys and "raid0_status" not in keys
|
||||
|
||||
|
||||
def test_a_row_that_no_longer_parses_is_asked_for_nothing():
|
||||
row = NS(presets=["system"], interfaces="eth0", custom="", version="2c")
|
||||
assert readings_for(row) == []
|
||||
|
||||
Reference in New Issue
Block a user