Files
FabledSteward/tests/integration/test_snmp_poll.py
T
bvandeusenandClaude Opus 5.5 fcd58224de
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m32s
CI / integration (push) Successful in 1m42s
CI / publish (push) Successful in 20s
feat(syslog): a device through Docker's address is placed by its name, or a device address set on it (#6096)
The SNMP poll asks each device's sysName in the same GET and keeps it
(snmp_003_sys_name). A syslog source told apart by name finds its own
address from the SNMP device (reported or given name) or host of that
name, so its lines reach the SNMP device's and host's pages again; the
edit page gains Device address, which always wins (syslog_004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:24:58 -04:00

140 lines
6.5 KiB
Python

"""Integration: an SNMP poll writes each device's outcome to its row and its
answers to the metric tables, a counter as a rate (milestone 491).
Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import importlib
import os
import uuid
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def _snmp(sub):
"""A submodule of the snmp plugin as the app loaded it: it is on by
default, so its models are already mapped under the loader's name, and
importing plugins.snmp.models would define the table a second time."""
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("snmp")
return importlib.import_module(f"{loaded.__name__ if loaded else 'plugins.snmp'}.{sub}")
def test_a_poll_records_why_a_device_is_silent_and_what_another_answered(app):
from sqlalchemy import select
from steward.models.metrics import PluginMetricLatest
scheduler = _snmp("scheduler")
SnmpDevice = _snmp("models").SnmpDevice
PollResult = _snmp("poller").PollResult
tag = uuid.uuid4().hex[:8]
up, down = f"sw-{tag}", f"ups-{tag}"
counts = iter([1_000.0, 61_000.0])
async def fake_poll(address, port, creds, oids):
if address == "10.9.9.9":
return PollResult(error="No reply from 10.9.9.9:161 within 5 s.")
keys = dict((k, o) for o, k in oids)
assert {"uptime", "if1_in", "if1_status"} <= set(keys) and creds.community == "public"
assert keys["_sys_name"] == "1.3.6.1.2.1.1.5.0" # the device's own name rides along
return PollResult(readings={"uptime": 8_640_000.0, "if1_status": 1.0, "if1_in": next(counts)},
texts={"_sys_name": "SW-ONE"}, missing=["if1_out"])
async def go():
from steward.core.crypto import encrypt_secret
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(SnmpDevice(name=up, address="10.9.9.1", presets=["system", "interfaces"],
interfaces="1", community=encrypt_secret("public")))
s.add(SnmpDevice(name=down, address="10.9.9.9", presets=["ups"],
community=encrypt_secret("public")))
scheduler.RATES = None
await scheduler._do_poll(app, poll=fake_poll)
first = await _latest(app, up)
await asyncio.sleep(1.1)
await scheduler._do_poll(app, poll=fake_poll)
async with app.db_sessionmaker() as s:
async with s.begin():
rows = {d.name: d for d in (await s.execute(
select(SnmpDevice).where(SnmpDevice.name.in_([up, down])))).scalars()}
for d in rows.values():
await s.delete(d)
return first, await _latest(app, up), rows
async def _latest(app, name):
async with app.db_sessionmaker() as s:
return {r.metric_name: r.value for r in (await s.execute(select(PluginMetricLatest).where(
PluginMetricLatest.source_module == "snmp", PluginMetricLatest.resource_name == name))).scalars()}
first, second, rows = asyncio.run(go())
assert rows[up].last_error is None and rows[up].last_ok_at is not None and rows[up].missing == ["if1_out"]
assert rows[up].sys_name == "SW-ONE" and rows[down].sys_name == ""
assert "_sys_name" not in (rows[up].texts or {})
assert rows[down].last_error == "No reply from 10.9.9.9:161 within 5 s." and rows[down].last_ok_at is None
assert rows[down].last_polled_at is not None
assert first == {"uptime": 8_640_000.0, "if1_status": 1.0} # a counter needs two samples
# 60,000 bytes over the 1.1 s or more between the polls, stored as bits
# per second: at most 436 kbit/s, and above 150 unless a poll took seconds.
assert 150_000 < second["if1_in"] <= 436_364
def test_a_nas_is_walked_once_then_read_by_row_its_states_stored_as_levels_and_shown_in_its_words(app):
from sqlalchemy import select
from steward.models.metrics import PluginMetricLatest
scheduler = _snmp("scheduler")
SnmpDevice = _snmp("models").SnmpDevice
poller = _snmp("poller")
name = f"nas-{uuid.uuid4().hex[:8]}"
walks = []
async def fake_walk(address, port, creds, tables):
walks.append([t.id for t in tables])
return poller.TableScan(rows={
"asustor_disks": [{"index": "1", "label": "Disk1", "columns": ["status", "temp"]}],
"asustor_volumes": [{"index": "1", "label": "Volume 1", "columns": ["status", "free"]}]})
async def fake_poll(address, port, creds, oids):
keys = {k for _, k in oids}
assert {"disk1_status", "disk1_temp", "volume1_status", "volume1_free"} <= keys
return poller.PollResult(readings={"disk1_temp": 41.0, "volume1_free": 100.0},
texts={"disk1_status": "Normal", "volume1_status": "Degraded"})
async def go():
from steward.core.crypto import encrypt_secret
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(SnmpDevice(name=name, address="10.9.9.5", presets=["asustor"],
community=encrypt_secret("public")))
scheduler.RATES = None
await scheduler._do_poll(app, poll=fake_poll, walk=fake_walk)
await scheduler._do_poll(app, poll=fake_poll, walk=fake_walk)
async with app.db_sessionmaker() as s:
async with s.begin():
row = (await s.execute(select(SnmpDevice).where(SnmpDevice.name == name))).scalar_one()
latest = {r.metric_name: r.value for r in (await s.execute(select(PluginMetricLatest).where(
PluginMetricLatest.source_module == "snmp", PluginMetricLatest.resource_name == name))).scalars()}
await s.delete(row)
return row, latest
row, latest = asyncio.run(go())
assert len(walks) == 1 and walks[0][:2] == ["asustor_disks", "asustor_volumes"] # not again within the hour
assert row.tables_at is not None and row.tables["asustor_volumes"][0]["label"] == "Volume 1"
assert row.texts == {"disk1_status": "Normal", "volume1_status": "Degraded"} and row.last_error is None
assert latest["disk1_status"] == 0.0 and latest["volume1_status"] == 2.0 and latest["disk1_temp"] == 41.0
assert latest["volume1_free"] == 100.0 * 2 ** 30 # ADM's GB, stored in bytes