SNMP is on by default, so the loader has already mapped snmp_devices under _steward_plugin_snmp; importing plugins.snmp.models defined the table twice (integration lane, run 8788). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.7 KiB
Python
88 lines
3.7 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"
|
|
return PollResult(readings={"uptime": 8_640_000.0, "if1_status": 1.0, "if1_in": next(counts)},
|
|
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:
|
|
rows = {d.name: d for d in (await s.execute(
|
|
select(SnmpDevice).where(SnmpDevice.name.in_([up, down])))).scalars()}
|
|
async with s.begin():
|
|
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[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
|