feat(snmp): a NAS's disk and volume I/O, CPU waiting on disk, available memory, interface errors (#5997)
- Synology: what each disk and volume reads and writes, bytes and requests a second and the load DSM gives it (SYNOLOGY-STORAGEIO- and -SPACEIO-MIB, 64-bit byte counters), and each disk's reconnects and sectors not found. - Linux server: CPU waiting on disk, the wait tick counter's share of the CPU's, and memory available (free + buffers + cache). A reading may now be worked out from others: the poll asks for its inputs, rates each counter, and stores only the result. - Interface traffic: errors and discards in and out, per second. - byte_count unit: a byte counter shown as bytes a second. Asustor's MIB has nothing more for this; its Linux server readings gain the same CPU wait and available memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,10 +17,12 @@ def test_interface_numbers_read_as_a_list():
|
||||
def test_interfaces_read_64_bit_counters_except_on_v1():
|
||||
v2 = {r.key: r for r in interface_readings([3], "2c")}
|
||||
v1 = {r.key: r for r in interface_readings([3], "1")}
|
||||
assert set(v2) == {"if3_status", "if3_in", "if3_out"}
|
||||
assert set(v2) == {"if3_status", "if3_in", "if3_out", "if3_in_errors", "if3_out_errors",
|
||||
"if3_in_discards", "if3_out_discards"}
|
||||
assert v2["if3_in"].oid == "1.3.6.1.2.1.31.1.1.1.6.3" and v2["if3_in"].counter
|
||||
assert v1["if3_in"].oid == "1.3.6.1.2.1.2.2.1.10.3"
|
||||
assert stored_unit(v2["if3_out"]) == "bits_per_second" and not v2["if3_status"].counter
|
||||
assert v2["if3_in_errors"].oid == "1.3.6.1.2.1.2.2.1.14.3" and stored_unit(v2["if3_in_errors"]) == "per_second"
|
||||
|
||||
|
||||
def test_a_custom_line_is_an_oid_a_name_and_a_unit():
|
||||
@@ -60,6 +62,7 @@ def test_presets_come_in_order_and_names_may_not_repeat():
|
||||
("kilobytes", 2048, "2.0 MiB"),
|
||||
("bits_per_second", 12_345_678, "12.3 Mbit/s"),
|
||||
("bits_per_second", 950, "950 bit/s"),
|
||||
("bytes_per_second", 3 * 1024 * 1024, "3.0 MiB/s"),
|
||||
("if_status", 2, "down"),
|
||||
("ups_battery", 3, "low"),
|
||||
("if_status", 99, "code 99"),
|
||||
@@ -140,3 +143,32 @@ 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) == "—"
|
||||
|
||||
|
||||
def test_cpu_waiting_on_disk_is_the_wait_counter_s_share_of_the_cpu_s():
|
||||
"""#5997: asked for as its five tick counters, kept as one percentage."""
|
||||
linux = {r.key: r for r in device_readings(["linux"], [], "", "2c")}
|
||||
wait = linux["cpu_iowait"]
|
||||
assert wait.oid == "" and stored_unit(wait) == "percent"
|
||||
assert [i.oid for i in wait.inputs] == [f"1.3.6.1.4.1.2021.11.{n}.0" for n in (50, 51, 52, 53, 54)]
|
||||
assert all(i.counter for i in wait.inputs)
|
||||
ticks = {"cpu_ticks_user": 100.0, "cpu_ticks_nice": 0.0, "cpu_ticks_system": 50.0,
|
||||
"cpu_ticks_idle": 800.0, "cpu_ticks_wait": 50.0}
|
||||
assert wait.combine(ticks) == 5.0
|
||||
assert wait.combine(dict.fromkeys(ticks, 0.0)) is None # no ticks between two polls
|
||||
assert linux["memory_available"].combine(
|
||||
{"memory_free_kb": 1000.0, "memory_buffers_kb": 200.0, "memory_cached_kb": 3000.0}) == 4200.0
|
||||
|
||||
|
||||
def test_a_synology_disk_and_volume_read_their_io_and_the_disk_its_reconnects():
|
||||
tables = {t.id: t for t in TABLES["synology"]}
|
||||
io = table_readings(tables["synology_disk_io"], [{"index": "1", "label": "sata1",
|
||||
"columns": ["read", "writes", "load"]}])
|
||||
assert [(r.oid, r.key, r.name, stored_unit(r)) for r in io] == [
|
||||
("1.3.6.1.4.1.6574.101.1.1.12.1", "storageio1_read", "sata1 read", "bytes_per_second"),
|
||||
("1.3.6.1.4.1.6574.101.1.1.6.1", "storageio1_writes", "sata1 writes", "per_second"),
|
||||
("1.3.6.1.4.1.6574.101.1.1.8.1", "storageio1_load", "sata1 load", "percent")]
|
||||
assert tables["synology_disk_io"].keeps("sata2") and not tables["synology_disk_io"].keeps("md2")
|
||||
assert tables["synology_volume_io"].columns[0].oid == "1.3.6.1.4.1.6574.102.1.1.12"
|
||||
disks = {c.key: c.oid for c in tables["synology_disks"].columns}
|
||||
assert disks["retries"] == "1.3.6.1.4.1.6574.2.1.1.8" and disks["identify_fails"] == "1.3.6.1.4.1.6574.2.1.1.10"
|
||||
|
||||
@@ -4,16 +4,24 @@ import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.snmp.scheduler as scheduler
|
||||
import plugins.snmp.service as service
|
||||
from plugins.snmp.poller import Credentials, PollResult
|
||||
|
||||
|
||||
def _device():
|
||||
return NS(id="d1", name="nas", address="10.0.0.5", port=161, version="2c", presets=["system"],
|
||||
def _device(presets=("system",)):
|
||||
return NS(id="d1", name="nas", address="10.0.0.5", port=161, version="2c", presets=list(presets),
|
||||
interfaces="", custom=None, tables={}, tables_at=None, last_error=None, texts={})
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _hung_session():
|
||||
await asyncio.Event().wait() # never answers: the device list comes from memory
|
||||
yield None
|
||||
|
||||
|
||||
def test_polls_go_on_while_the_database_hangs_and_are_stored_in_order_after(monkeypatch):
|
||||
async def main():
|
||||
stalled = asyncio.Event()
|
||||
@@ -53,3 +61,50 @@ def test_polls_go_on_while_the_database_hangs_and_are_stored_in_order_after(monk
|
||||
assert [[(p.name, p.values) for p in r] for r in rounds] == [
|
||||
[("nas", [("uptime", 100.0)])], [("nas", [("uptime", 200.0)])]]
|
||||
assert rounds[0][0].at < rounds[1][0].at # each stored at the time it was collected
|
||||
|
||||
|
||||
def test_a_reading_worked_out_from_others_is_asked_for_by_its_inputs_and_kept_alone(monkeypatch):
|
||||
"""#5997: CPU waiting on disk from the tick counters' rates, and available
|
||||
memory from three gauges; the inputs are neither stored nor named missing."""
|
||||
mem = {"memory_free_kb": 1000.0, "memory_buffers_kb": 200.0, "memory_cached_kb": 3000.0}
|
||||
answers = iter([
|
||||
{"cpu_ticks_user": 1000.0, "cpu_ticks_nice": 0.0, "cpu_ticks_system": 500.0,
|
||||
"cpu_ticks_idle": 8000.0, "cpu_ticks_wait": 500.0} | mem,
|
||||
{"cpu_ticks_user": 1100.0, "cpu_ticks_nice": 0.0, "cpu_ticks_system": 550.0,
|
||||
"cpu_ticks_idle": 8800.0, "cpu_ticks_wait": 550.0} | mem,
|
||||
{"cpu_ticks_user": 1200.0, "cpu_ticks_nice": 0.0, "cpu_ticks_system": 600.0,
|
||||
"cpu_ticks_idle": 9600.0} | mem]) # this time it doesn't answer the wait counter
|
||||
asked, stored = [], []
|
||||
|
||||
async def poll(address, port, creds, oids):
|
||||
asked.append(dict((k, o) for o, k in oids))
|
||||
got = next(answers)
|
||||
return PollResult(readings=got, missing=[k for _, k in oids if k not in got])
|
||||
|
||||
async def store(app_, rounds):
|
||||
stored.extend(rounds)
|
||||
|
||||
async def main():
|
||||
monkeypatch.setattr(scheduler, "DEVICES", [_device(["linux"])])
|
||||
monkeypatch.setattr(scheduler, "DEVICES_READ_S", 0.05)
|
||||
monkeypatch.setattr(scheduler, "_store", store)
|
||||
monkeypatch.setattr(service, "credentials", lambda d: Credentials(community="public"))
|
||||
scheduler.RATES = None
|
||||
app = NS(db_sessionmaker=_hung_session)
|
||||
for _ in range(3):
|
||||
await scheduler._do_poll(app, poll=poll, walk=_no_tables)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
asyncio.run(main())
|
||||
assert asked[0]["cpu_ticks_wait"] == "1.3.6.1.4.1.2021.11.54.0" and "cpu_iowait" not in asked[0]
|
||||
assert "" not in asked[0].values()
|
||||
first, second, third = (dict(r[0].values) for r in stored)
|
||||
assert first == {"memory_available": 4200.0} # a counter needs two samples
|
||||
assert second == {"cpu_iowait": pytest.approx(5.0), "memory_available": 4200.0} # 50 of 1,000 ticks waited
|
||||
assert third == {"memory_available": 4200.0}
|
||||
assert "cpu_iowait" in stored[2][0].missing and not any(k.startswith("cpu_ticks") for k in stored[2][0].missing)
|
||||
|
||||
|
||||
async def _no_tables(address, port, creds, tables):
|
||||
from plugins.snmp.poller import TableScan
|
||||
return TableScan(rows={})
|
||||
|
||||
Reference in New Issue
Block a user