Files
FabledSteward/plugins/snmp/scheduler.py
T
bvandeusen 4fc8c96c41
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m21s
CI / publish (push) Successful in 1m16s
fix(snmp): bundle pysnmp in image and port poller to the asyncio HLAPI
The SNMP plugin ships in the image but logged "pysnmp not installed — SNMP
polling disabled" on every poll, so polling never worked. Two coupled defects:

1. The Dockerfile installed only `.[ansible]`, so the `snmp` extra (pysnmp)
   was never bundled even though the plugin is first-party and shipped.
2. poller.py used the synchronous pysnmp HLAPI (`next(getCmd(...))`), which
   pysnmp-lextudio 6.x removed — it's asyncio-only now — so even with the dep
   present, polling would have thrown and silently returned nothing. The 5.x
   line that still has the sync API isn't safe on the image's Python 3.13.

Fix:
- Dockerfile: install `.[ansible,snmp]`.
- poller.py: `poll_device_sync` → `async def poll_device` on the asyncio HLAPI,
  with a dual-version import (pysnmp 7.x `pysnmp.hlapi.v3arch.asyncio`/`get_cmd`
  + async `UdpTransportTarget.create`; pysnmp-lextudio 6.2.x
  `pysnmp.hlapi.asyncio`/`getCmd` + direct `UdpTransportTarget`) so a dependency
  bump can't silently re-break it.
- scheduler.py: await poll_device directly; drop the run_in_executor wrapper
  and the now-unused asyncio import.
- Add tests/plugins/snmp/test_poller.py covering the version→mpModel mapping,
  that the poller is a coroutine, and the graceful no-pysnmp path.

Note: CI confirms import/load and the no-pysnmp path, but has no SNMP target —
live polling against real devices is verified after deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-19 13:28:07 -04:00

70 lines
2.2 KiB
Python

# plugins/snmp/scheduler.py
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from quart import Quart
from steward.core.scheduler import ScheduledTask
logger = logging.getLogger(__name__)
def make_poll_task(app: "Quart") -> ScheduledTask:
interval = app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)
async def poll() -> None:
await _do_poll(app)
return ScheduledTask(
name="snmp_poll",
coro_factory=poll,
interval_seconds=int(interval),
run_on_startup=True,
)
async def _do_poll(app: "Quart") -> None:
from .poller import poll_device
from steward.core.alerts import record_metric
devices: list[dict] = app.config["PLUGINS"]["snmp"].get("devices", [])
if not devices:
return
async with app.db_sessionmaker() as session:
async with session.begin():
for device in devices:
if not isinstance(device, dict):
continue
name = device.get("name") or device.get("host", "unknown")
host = device.get("host", "")
port = int(device.get("port", 161))
community = device.get("community", "public")
version = str(device.get("version", "2c"))
oids = device.get("oids", [])
if not host or not oids:
continue
try:
readings = await poll_device(host, port, community, version, oids)
except Exception:
logger.exception("SNMP poll failed for device %s (%s)", name, host)
continue
for label, value in readings.items():
await record_metric(
session=session,
source_module="snmp",
resource_name=name,
metric_name=label,
value=value,
)
if readings:
logger.debug("SNMP polled %s (%s): %d OID(s)", name, host, len(readings))
else:
logger.debug("SNMP polled %s (%s): no readings", name, host)