From 6ff9cffed461246bc5bfb32c48b05944b9b09a62 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 18 Oct 2026 23:12:59 -0400 Subject: [PATCH] fix(syslog): word its audit actions, list its tables for backups, mark its page top-level, reach its modules as loaded (#5999) - Audit: syslog.source_* worded, and a Syslog filter area. - Backups keep syslog_sources (added, named, ignored; what it last sent isn't compared) and leave syslog_lines out, as logs. - The integration test reaches the plugin as loaded (#5775), so its tables aren't defined twice; ruff E741. Co-Authored-By: Claude Opus 5.5 --- steward/audit/service.py | 7 ++++++- steward/backup/contents.py | 8 +++++++- tests/integration/test_syslog.py | 24 ++++++++++++++++-------- tests/plugins/syslog/test_syslog_page.py | 2 +- tests/test_breadcrumbs.py | 1 + 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/steward/audit/service.py b/steward/audit/service.py index 3e61c8d..07005e4 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -198,6 +198,11 @@ ACTIONS = { "prometheus.template_created": "added Prometheus widget template", "prometheus.template_updated": "changed Prometheus widget template", "prometheus.template_deleted": "deleted Prometheus widget template", + "syslog.source_added": "added syslog source", + "syslog.source_updated": "changed syslog source", + "syslog.source_removed": "removed syslog source", + "syslog.source_ignored": "ignored syslog sender", + "syslog.source_offered": "offered again syslog sender", "backup.made": "sent a backup", "backup.failed": "tried to send a backup, which failed:", "backup.restored": "restored Steward from a backup", @@ -210,7 +215,7 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), (" ("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"), ("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"), ("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"), - ("backup.", "Backups")) + ("syslog.", "Syslog"), ("backup.", "Backups")) def action_text(action: str) -> str: diff --git a/steward/backup/contents.py b/steward/backup/contents.py index 2e1f776..29f90fa 100644 --- a/steward/backup/contents.py +++ b/steward/backup/contents.py @@ -26,6 +26,8 @@ KEPT: frozenset[str] = frozenset({ "snmp_devices", "prometheus_targets", "prometheus_widget_templates", # Names given to network clients by hand (UniFi's alias column). "unifi_known_clients", + # Syslog senders: which were added, under what name, and which ignored. + "syslog_sources", }) # Rows that are measurements, logs, history or in-flight work: left out. @@ -58,6 +60,7 @@ EXCLUDED: frozenset[str] = frozenset({ "unifi_alarms", "unifi_client_snapshots", "unifi_devices", "unifi_dpi_snapshots", "unifi_events", "unifi_fw_rules", "unifi_networks", "unifi_port_forwards", "unifi_speedtest_results", "unifi_wan_stats", "unifi_wlan_stats", + "syslog_lines", }) # ── What a check compares (#5823) ──────────────────────────────────────────── @@ -82,6 +85,9 @@ ACTIVITY: dict[str, frozenset[str]] = { "snmp_devices": frozenset({"last_polled_at", "last_ok_at", "last_error", "missing"}), "prometheus_targets": frozenset({"last_scraped_at", "last_ok_at", "last_error", "last_kept", "last_dropped", "last_capped"}), + # What the sender last sent; its address, name, state and host are setup. + "syslog_sources": frozenset({"hostname_seen", "first_seen_at", "last_seen_at", "lines_total", + "last_severity", "last_line"}), # Scraped from the controller; the name and note are what someone set. "unifi_known_clients": frozenset({"hostname", "ip", "mac_vendor", "is_blocked", "first_seen", "last_seen", "scraped_at"}), @@ -109,5 +115,5 @@ AREAS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("Ansible", ("ansible_targets", "ansible_groups", "ansible_target_groups", "ansible_schedules")), ("Dashboards", ("dashboards", "dashboard_widgets", "dashboard_share_tokens")), ("Plugins", ("snmp_devices", "prometheus_targets", "prometheus_widget_templates", - "unifi_known_clients")), + "unifi_known_clients", "syslog_sources")), ) diff --git a/tests/integration/test_syslog.py b/tests/integration/test_syslog.py index 95ebe7d..2ef5a30 100644 --- a/tests/integration/test_syslog.py +++ b/tests/integration/test_syslog.py @@ -20,17 +20,25 @@ def app(): return create_app(testing=False) +def _mod(sub): + """The loaded plugin's module: importing plugins.syslog.models by path + would define its tables a second time (#5775).""" + from steward.core.plugin_manager import loaded_plugin_submodule + module = loaded_plugin_submodule("syslog", sub) + assert module is not None, "the syslog plugin isn't loaded" + return module + + def _received(address, text, at): - from plugins.syslog.parse import parse - from plugins.syslog.receiver import Received - return Received(at, address, parse(f"<11>Oct 19 01:12:03 synnas kernel: {text}")) + receiver = _mod("receiver") + return receiver.Received(at, address, _mod("parse").parse(f"<11>Oct 19 01:12:03 synnas kernel: {text}")) def test_an_unknown_sender_is_offered_and_an_added_one_kept(app): from sqlalchemy import delete, func, select - from plugins.syslog.models import SyslogLine, SyslogSource - from plugins.syslog.receiver import store_lines + SyslogLine, SyslogSource = _mod("models").SyslogLine, _mod("models").SyslogSource + store_lines = _mod("receiver").store_lines now = datetime(2026, 10, 19, 3, 0, tzinfo=timezone.utc) @@ -54,7 +62,7 @@ def test_an_unknown_sender_is_offered_and_an_added_one_kept(app): await store_lines(s, [_received("10.9.0.1", "kept", now + timedelta(seconds=2))]) async with app.db_sessionmaker() as s: lines = (await s.execute(select(SyslogLine))).scalars().all() - assert [(l.app, l.hostname, l.message, l.severity) for l in lines] == [("kernel", "synnas", "kept", 3)] + assert [(ln.app, ln.hostname, ln.message, ln.severity) for ln in lines] == [("kernel", "synnas", "kept", 3)] assert (await s.get(SyslogSource, src_id)).lines_total == 3 async with s.begin(): await s.execute(delete(SyslogSource)) @@ -65,8 +73,8 @@ def test_an_unknown_sender_is_offered_and_an_added_one_kept(app): def test_the_ring_keeps_each_source_within_its_window_and_cap(app): from sqlalchemy import delete, select - from plugins.syslog.models import SyslogLine, SyslogSource - from plugins.syslog.retention import prune_syslog_lines + SyslogLine, SyslogSource = _mod("models").SyslogLine, _mod("models").SyslogSource + prune_syslog_lines = _mod("retention").prune_syslog_lines now = datetime(2026, 10, 19, 3, 0, tzinfo=timezone.utc) line40 = "x" * 40 # cap 100 keeps 3 (prefixes 0, 40, 80) diff --git a/tests/plugins/syslog/test_syslog_page.py b/tests/plugins/syslog/test_syslog_page.py index 71df839..d1c3125 100644 --- a/tests/plugins/syslog/test_syslog_page.py +++ b/tests/plugins/syslog/test_syslog_page.py @@ -56,7 +56,7 @@ async def test_a_viewer_sees_sources_but_no_controls(app): async def test_a_port_it_cannot_open_is_said(app): page = await _render(app, "admin", listener=_listener( listening=False, error="Port 514 couldn't be opened (UDP: Permission denied).")) - assert "Not listening" in page and "Port 514 couldn't be opened" in page + assert "Not listening" in page and "Port 514 couldn't be opened" in page assert "below 1024" in page assert "No sources yet." in page diff --git a/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py index b9d5777..2c5fcec 100644 --- a/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -24,6 +24,7 @@ TOP_LEVEL = { "plugins/prometheus/templates/prometheus/index.html", "plugins/traefik/templates/traefik/index.html", "plugins/unifi/templates/unifi/index.html", + "plugins/syslog/templates/syslog/index.html", # Logged out, or no page to go back up from. "steward/templates/auth/login.html", "steward/templates/auth/setup.html",