Files
FabledSteward/steward/audit/service.py
T
bvandeusenandClaude Opus 5.5 9ca2e38fa1
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:11 -04:00

248 lines
11 KiB
Python

"""Name the thing each audit entry is about.
An audit row stores an entity type and id; the page shows the entity's
current name, linked to its page, in one query per type for the whole page.
An entity deleted since keeps whatever name the entry's own detail recorded.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from steward.ansible.inventory_gen import run_title, scope_labels
from steward.core.formatting import day_group
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.ansible import AnsibleRun
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.api_tokens import ApiToken
from steward.models.hosts import Host
from steward.models.monitors import Monitor
logger = logging.getLogger(__name__)
# entity_type -> (model, its page for an id). A type named by a value rather
# than a row (plugin, ansible_playbook) is its own label.
_NAMED = {
"host": (Host, "/hosts/{}"),
"monitor": (Monitor, "/monitors/{}"),
"alert_rule": (AlertRule, "/alerts/history?rule={}"),
"maintenance_window": (MaintenanceWindow, "/alerts/maintenance"),
"ansible_target": (AnsibleTarget, "/ansible/inventory/targets/{}"),
"ansible_group": (AnsibleGroup, "/ansible/inventory/groups/{}"),
"ansible_schedule": (AnsibleSchedule, "/ansible/schedules"),
"api_token": (ApiToken, "/ai-access/"),
}
_SELF_NAMED = {"plugin": "/settings/plugins/", "ansible_playbook": None}
def _named_types() -> dict:
"""Core's named entity types plus those a loaded plugin names through its
get_audit_entities() hook ({type: (model, page for an id)}), so core
reads a plugin's rows without importing it."""
from steward.core.plugin_manager import get_loaded_plugin_modules
named = dict(_NAMED)
for name, module in get_loaded_plugin_modules().items():
hook = getattr(module, "get_audit_entities", None)
if hook is None:
continue
try:
named.update(hook() or {})
except Exception:
logger.exception("Plugin %r: get_audit_entities() raised; its entries show by id", name)
return named
def _recorded_name(detail: dict) -> str | None:
"""A name the entry recorded when it was written, for a deleted entity."""
for key in ("name", "new_name", "username", "playbook"):
if detail.get(key):
return str(detail[key])
return None
async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
"""{(entity_type, entity_id): {"label", "url", "gone"}} for the audit rows
in `items` ({"event", "detail"}), batched one query per type."""
ids: dict[str, set[str]] = {}
for it in items:
e = it["event"]
if e.entity_type and e.entity_id:
ids.setdefault(e.entity_type, set()).add(str(e.entity_id))
named = _named_types()
labels: dict[tuple[str, str], dict] = {}
for etype, wanted in ids.items():
if etype in named:
model, page = named[etype]
key_col = model.id
keys = [int(i) for i in wanted if i.isdigit()] if etype == "api_token" else list(wanted)
if not keys:
continue
for row_id, name in (await db.execute(
select(key_col, model.name).where(key_col.in_(keys)))).all():
labels[(etype, str(row_id))] = {"label": name, "url": page.format(row_id), "gone": False}
elif etype == "ansible_run":
runs = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(list(wanted))))).scalars().all()
scopes = await scope_labels(db, [r.inventory_scope for r in runs])
for r in runs:
labels[(etype, r.id)] = {
"label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)),
"url": f"/ansible/runs/{r.id}", "gone": False}
elif etype in _SELF_NAMED:
for i in wanted:
labels[(etype, i)] = {"label": i, "url": _SELF_NAMED[etype], "gone": False}
# What a lookup did not find was deleted since: keep the name the entry recorded.
for it in items:
e = it["event"]
if not (e.entity_type and e.entity_id):
continue
key = (e.entity_type, str(e.entity_id))
if key not in labels:
recorded = _recorded_name(it["detail"] or {})
labels[key] = {"label": recorded or f"{str(e.entity_id)[:8]}", "url": None, "gone": True}
return labels
# What each action did, said as the rest of a sentence that starts with who
# did it: "admin paused monitor api check". An action not listed here is
# shown by its code, which is also every row's tooltip and what the filter
# matches.
ACTIONS = {
"auth.login": "signed in",
"auth.login_failed": "failed to sign in",
"auth.setup": "set up Steward",
"account.time_zone": "set their time zone",
"host.created": "added host",
"host.renamed": "renamed host",
"host.deleted": "deleted host",
"monitor.created": "added monitor",
"monitor.updated": "changed monitor",
"monitor.enabled": "resumed monitor",
"monitor.disabled": "paused monitor",
"monitor.deleted": "deleted monitor",
"alert_rule.created": "added alert rule",
"alert_rule.updated": "changed alert rule",
"alert_rule.enabled": "turned on alert rule",
"alert_rule.disabled": "turned off alert rule",
"alert_rule.deleted": "deleted alert rule",
"alert_rule.acknowledged": "acknowledged the alert from",
"alert_rule.action_set": "set the playbook run by alert rule",
"alert_rule.action_cleared": "removed the playbook from alert rule",
"maintenance_window.created": "scheduled maintenance",
"maintenance_window.deleted": "deleted maintenance window",
"maintenance_window.ended": "ended maintenance window",
"maintenance_window.cancelled": "cancelled maintenance window",
"ansible.run.started": "started",
"ansible.run.cancelled": "cancelled",
"ansible.host_run": "ran a playbook on host",
"ansible.schedule.created": "added schedule",
"ansible.schedule.updated": "changed schedule",
"ansible.schedule.enabled": "resumed schedule",
"ansible.schedule.disabled": "paused schedule",
"ansible.schedule.deleted": "deleted schedule",
"ansible.schedule.run_now": "ran schedule now",
"ansible.playbook.created": "added playbook",
"ansible.playbook.updated": "edited playbook",
"ansible.playbook.deleted": "deleted playbook",
"ansible.target.created": "added inventory target",
"ansible.target.updated": "changed inventory target",
"ansible.target.deleted": "deleted inventory target",
"ansible.group.created": "added inventory group",
"ansible.group.updated": "changed inventory group",
"ansible.group.deleted": "deleted inventory group",
"settings.saved": "saved settings",
"settings.notification.tested": "sent a test notification",
"plugin.enabled": "turned on plugin",
"plugin.disabled": "turned off plugin",
"plugin.install": "installed plugin",
"plugin.install_failed": "failed to install plugin",
"plugin.settings_removed": "removed the settings of plugin",
"api_token.create": "created AI access token",
"api_token.revoke": "revoked AI access token",
"docker.prune.started": "started a Docker cleanup on host",
"snmp.device_created": "added SNMP device",
"snmp.device_updated": "changed SNMP device",
"snmp.device_deleted": "deleted SNMP device",
}
# The filter's choices: an area, matched as a prefix of the action code.
AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("host.", "Hosts"), ("monitor.", "Monitors"),
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
("docker.", "Docker"), ("snmp.", "SNMP"))
def action_text(action: str) -> str:
return ACTIONS.get(action, action)
# Detail keys a row does not need to repeat: the entity column already names
# and links the thing, and the scope's own wording is shown in its place.
_SKIP = {"rule_id", "monitor_id", "host_id", "window_id", "schedule_id", "scope_label"}
_LABELS = {"check": "dry run", "interval_seconds": "every", "interval_s": "every",
"bytes": "size", "renamed_from": "was", "token_id": "AI token", "vars": "variables",
"extra_vars": "variables", "secret_vars": "secret variables", "sent": "delivered",
"what": "removing", "address": "address", "carried": "history carried over"}
_METHODS = {"local": "password", "ldap": "LDAP", "oidc": "single sign-on"}
_SECTIONS = {"general": "General", "thresholds": "Thresholds", "notifications": "Notifications",
"ansible.managed_key": "Ansible: managed SSH key",
"ansible.credentials": "Ansible: credentials", "auth": "Authentication"}
def _plain(value) -> str:
if isinstance(value, bool):
return "yes" if value else "no"
if isinstance(value, (list, tuple)):
return ", ".join(_plain(v) for v in value) if value else "none"
if isinstance(value, dict):
return ", ".join(f"{str(k).replace('_', ' ')} {_plain(v)}" for k, v in value.items()) or "none"
return str(value)
def detail_facts(detail: dict, scopes: dict[str, str]) -> list[dict]:
"""An entry's detail as [{"label", "text", "url"}] a reader can take in:
yes/no for flags, lists joined, a run's scope in words (scope_labels),
a span as a span, and nothing the entity column already says."""
from steward.core.formatting import human_bytes, human_duration
facts = []
for key, value in (detail or {}).items():
if key in _SKIP or value is None:
continue
label, url = _LABELS.get(key, key.replace("_", " ")), None
if key == "scope":
text = scopes.get(value, value)
elif key in ("interval_seconds", "interval_s"):
text = human_duration(value)
elif key == "bytes":
text = human_bytes(value)
elif key == "method":
text = _METHODS.get(value, value)
elif key == "section":
text = _SECTIONS.get(value, value)
elif key == "token_id":
text = f"#{value}"
elif key == "run_id":
label, text, url = "run", "open", f"/ansible/runs/{value}"
else:
text = _plain(value)
facts.append({"label": label, "text": text, "url": url})
return facts
def mark_day_groups(items: list[dict], tz, group: str = "", now=None) -> str:
"""Give each newest-first entry its recency group (formatting.day_group,
in the reader's zone) and whether it opens one, so the page draws a
divider there. `group` is the one the previous page ended in: a group
split across pages is opened once. Returns the group this page ends in."""
for item in items:
item["group"] = day_group(item["event"].timestamp, tz, now)
item["opens_group"] = item["group"] != group
group = item["group"]
return group