fix(snmp): a disk listed late in UCD-DISKIO is read, and Asustor disks are named by bay (#5992)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m50s
CI / publish (push) Successful in 1m29s

The table walk stopped after 64 raw entries, counting the RAM disks, loops,
partitions and md arrays that rows_like then drops, so a Synology's eighth
disk was never reached. The walk is now bounded at 1,024 entries per column;
64 still caps the rows kept.

ADM labels its disks "Disk0".."Disk4" while their indexes run 1..5; the
Asustor disk table now names each row by its index ("Disk 1"). Keys are
unchanged, so history carries on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 23:16:25 -04:00
co-authored by Claude Opus 5.5
parent d065cafd11
commit bd1c32d2a4
4 changed files with 30 additions and 6 deletions
+7 -3
View File
@@ -222,9 +222,13 @@ async def poll_device(address: str, port: int, creds: Credentials,
_close_engine(engine) _close_engine(engine)
# Rows read of one table: enough for any NAS's disks and volumes, and a bound # Rows kept of one table: enough for any NAS's disks and volumes.
# on a device that answers a walk forever.
ROWS_PER_TABLE = 64 ROWS_PER_TABLE = 64
# Entries walked of one column, before rows_like keeps the physical disks: a
# Synology's UCD-DISKIO lists RAM disks, loops, partitions and md arrays ahead
# of a disk added late, so the bound is on the walk, not on the rows kept, and
# it only stops a device that answers a walk forever.
ENTRIES_PER_COLUMN = 1024
async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, object] | str: async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, object] | str:
@@ -243,7 +247,7 @@ async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, o
if type(val).__name__ in _ABSENT or not oid.startswith(column + "."): if type(val).__name__ in _ABSENT or not oid.startswith(column + "."):
continue continue
found[oid[len(column) + 1:]] = val found[oid[len(column) + 1:]] = val
if len(found) >= ROWS_PER_TABLE: if len(found) >= ENTRIES_PER_COLUMN:
break break
return found return found
+8 -3
View File
@@ -132,13 +132,16 @@ class Table:
by its label and column ("Disk 1 status"). A table with no label column by its label and column ("Disk 1 status"). A table with no label column
(one temperature sensor, one memory) is keyed and named by its columns (one temperature sensor, one memory) is keyed and named by its columns
alone while it has one row. `rows_like` keeps only rows whose label alone while it has one row. `rows_like` keeps only rows whose label
matches it (physical disks, not partitions and loop devices).""" matches it (physical disks, not partitions and loop devices).
`by_index` names each row by its noun and index instead ("Disk 1"), for
a device whose own label doesn't say which bay it is (ADM's "Disk0")."""
id: str id: str
prefix: str prefix: str
label: str label: str
noun: str noun: str
columns: tuple[Column, ...] columns: tuple[Column, ...]
rows_like: str = "" rows_like: str = ""
by_index: bool = False
def column_oids(self) -> list[str]: def column_oids(self) -> list[str]:
return ([self.label] if self.label else []) + [c.oid for c in self.columns] return ([self.label] if self.label else []) + [c.oid for c in self.columns]
@@ -154,7 +157,9 @@ def table_readings(table: Table, rows: list[dict]) -> list[Reading]:
alone = not table.label and len(rows) == 1 alone = not table.label and len(rows) == 1
for row in rows: for row in rows:
index, label = str(row["index"]), str(row.get("label") or "") index, label = str(row["index"]), str(row.get("label") or "")
if label.isdigit() or (table.label and not label): if table.by_index:
label = f"{table.noun} {index}"
elif label.isdigit() or (table.label and not label):
label = f"{table.noun} {label or index}" label = f"{table.noun} {label or index}"
for c in table.columns: for c in table.columns:
if c.key not in row.get("columns", ()): if c.key not in row.get("columns", ()):
@@ -233,7 +238,7 @@ TABLES: dict[str, tuple[Table, ...]] = {
Table("asustor_disks", "disk", _asus("4.1.1.2"), "Disk", Table("asustor_disks", "disk", _asus("4.1.1.2"), "Disk",
(Column(_asus("4.1.1.5"), "status", "status", "state", states="words"), (Column(_asus("4.1.1.5"), "status", "status", "state", states="words"),
Column(_asus("4.1.1.6"), "smart", "SMART", "state", states="words"), Column(_asus("4.1.1.6"), "smart", "SMART", "state", states="words"),
Column(_asus("4.1.1.7"), "temp", "temperature", "celsius"))), Column(_asus("4.1.1.7"), "temp", "temperature", "celsius")), by_index=True),
Table("asustor_volumes", "volume", _asus("5.1.1.2"), "Volume", Table("asustor_volumes", "volume", _asus("5.1.1.2"), "Volume",
(Column(_asus("5.1.1.4"), "status", "status", "state", states="words"), (Column(_asus("5.1.1.4"), "status", "status", "state", states="words"),
Column(_asus("5.1.1.7"), "free", "free", "bytes", scale=_GIB), Column(_asus("5.1.1.7"), "free", "free", "bytes", scale=_GIB),
+9
View File
@@ -185,6 +185,15 @@ def test_a_table_walk_finds_each_row_its_label_and_the_columns_it_answered(monke
{"index": "1", "label": "Disk 1", "columns": ["status", "temp"]}]} {"index": "1", "label": "Disk 1", "columns": ["status", "temp"]}]}
def test_a_disk_listed_after_many_partitions_and_loops_is_still_found(monkeypatch):
from plugins.snmp.readings import TABLES
[diskio] = TABLES["linux"]
labels = {str(i): f"loop{i}" for i in range(1, 80)} | {"80": "sdh", "81": "sdh1"}
_walk(monkeypatch, {diskio.label: labels, diskio.columns[0].oid: {i: 5 for i in labels}})
scan = asyncio.run(poller.walk_tables("10.0.0.2", 161, Credentials(community="public"), [diskio]))
assert scan.rows == {"ucd_diskio": [{"index": "80", "label": "sdh", "columns": ["busy"]}]}
def test_a_table_walk_with_no_reply_says_what_to_check(monkeypatch): def test_a_table_walk_with_no_reply_says_what_to_check(monkeypatch):
from plugins.snmp.readings import Column, Table from plugins.snmp.readings import Column, Table
_walk(monkeypatch, {}, error="No SNMP response received before timeout") _walk(monkeypatch, {}, error="No SNMP response received before timeout")
+6
View File
@@ -86,6 +86,12 @@ def test_a_table_row_is_keyed_by_its_index_and_named_by_its_label():
assert got[0].states == "words" assert got[0].states == "words"
def test_an_asustor_disk_is_named_by_its_bay_not_adms_zero_based_label():
disks = next(t for t in TABLES["asustor"] if t.id == "asustor_disks")
got = table_readings(disks, [{"index": "1", "label": "Disk0", "columns": ["status"]}])
assert [(r.key, r.name) for r in got] == [("disk1_status", "Disk 1 status")]
def test_a_table_without_labels_is_named_by_its_columns(): def test_a_table_without_labels_is_named_by_its_columns():
fans = Table("f", "", "", "Fan", (Column("9.2", "fan", "Fan speed", "rpm"),)) fans = Table("f", "", "", "Fan", (Column("9.2", "fan", "Fan speed", "rpm"),))
[one] = table_readings(fans, [{"index": "1", "label": "", "columns": ["fan"]}]) [one] = table_readings(fans, [{"index": "1", "label": "", "columns": ["fan"]}])