Files
FabledSteward/tests/mcp/test_anomaly_review.py
T
bvandeusenandClaude Opus 5.5 91bb199d14 fix(mcp): get_anomaly shows Steward's own restarts beside every anomaly, and a router's containers (#5862)
All 16 Traefik 4xx anomalies on 2026-10-15 came back with nothing
around them, though 15 were Steward's own restarts. A router has no
host, and every events source was read by host.

- Core: each break of more than 90 s in Steward's once-a-minute health
  sample is a "steward" event beside every anomaly, placed or not.
- Plugins: an optional mcp_resource_events(db, source, resource, since,
  until) for a resource with no host. Traefik's gives the container
  events of the swarm service behind a router, on any host.

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

130 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The anomaly review tools' pure parts and refusals (#5837): slot names, the
line an anomaly opens at, how far a series reaches, the plugin events hook,
and calls refused before anything is read or changed.
get_anomaly's circumstances, a bulk review and a settings change run against
Postgres in tests/integration/test_mcp_anomaly_review.py.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import pytest
from steward.core import alerts
from steward.mcp.tools import _events, anomalies
from steward.models.users import UserRole
NOW = datetime(2026, 10, 15, 12, 0, tzinfo=timezone.utc)
def test_slots_are_named_as_a_reader_would():
assert anomalies.slot_label("day", 3) == "03:00 UTC"
assert anomalies.slot_label("week", 0) == "Mon 00:00 UTC"
assert anomalies.slot_label("week", 6 * 24 + 23) == "Sun 23:00 UTC"
def test_an_anomaly_opens_above_the_larger_of_the_factor_and_the_floor():
assert anomalies.flag_above(20.0, 10.0) == 30.0 # 20 + floor beats 20 × 1.25
assert anomalies.flag_above(100.0, 10.0) == 125.0 # the factor beats the floor
def test_a_series_reaches_as_far_as_the_occurrence_lasted_within_one_to_six_hours():
assert anomalies.series_pad(NOW - timedelta(minutes=20), NOW, NOW) == timedelta(hours=1)
assert anomalies.series_pad(NOW - timedelta(hours=3), NOW, NOW) == timedelta(hours=3)
assert anomalies.series_pad(NOW - timedelta(days=2), None, NOW) == timedelta(hours=6)
def test_an_event_says_when_what_and_until():
e = _events.event(NOW, "downtime", "Rebooted (60 s)", until=NOW + timedelta(minutes=1))
assert e == {"at": "2026-10-15T12:00:00Z", "until": "2026-10-15T12:01:00Z", "kind": "downtime",
"what": "Rebooted (60 s)", "subject": None, "ids": {}}
def test_a_resource_names_its_host_through_the_source_that_feeds_it():
alerts.agent_fed("test_src", lambda r: r.split(":", 1)[0])
try:
assert alerts.host_of_resource("test_src", "nas:/var") == "nas"
assert alerts.host_of_resource("no_such_source", "nas") is None
finally:
alerts._AGENT_FED.pop("test_src", None)
def test_same_host_covers_the_resource_itself_and_its_hosts_other_series():
alerts.agent_fed("test_src", lambda r: r.split(":", 1)[0])
try:
place = _events.Place("test_src", "nas", "h1", "nas")
assert _events._same_host("test_src", "nas:/var", place)
assert not _events._same_host("test_src", "other", place)
bare = _events.Place("traefik", "router-a")
assert _events._same_host("traefik", "router-a", bare)
assert not _events._same_host("traefik", "router-b", bare)
finally:
alerts._AGENT_FED.pop("test_src", None)
async def test_refusals_change_nothing():
with pytest.raises(ValueError, match="at least one anomaly"):
await anomalies.review_anomaly([], "normal")
with pytest.raises(ValueError, match="At most"):
await anomalies.review_anomaly([f"id-{i}" for i in range(anomalies.REVIEW_MAX + 1)], "real")
with pytest.raises(ValueError, match="No floor named bogus"):
await anomalies.set_anomaly_settings(floors={"bogus": 1})
with pytest.raises(ValueError, match="0 or more"):
await anomalies.set_anomaly_settings(floors={"host_cpu_pct": -1})
with pytest.raises(ValueError, match="Nothing to change"):
await anomalies.set_anomaly_settings()
with pytest.raises(ValueError, match="doesn't learn a normal"):
await anomalies.get_metric_normal("no_source", "x", "no_metric")
def test_anomaly_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name, role, read_only in (("list_anomalies", UserRole.viewer, True),
("get_anomaly", UserRole.viewer, True),
("get_metric_normal", UserRole.viewer, True),
("get_anomaly_settings", UserRole.viewer, True),
("review_anomaly", UserRole.operator, False),
("set_anomaly_settings", UserRole.admin, False)):
assert mcp.tool_roles[name] is role, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
def test_a_plugin_adds_events_through_its_hook(app):
async def events(db, host_id, host_name, since, until):
return []
mcp = app.mcp_server
mcp.register_plugin("events_test", NS(mcp_events=events))
try:
assert mcp.event_sources["events_test"] is events
mcp.register_plugin("events_test", NS())
assert "events_test" not in mcp.event_sources
finally:
mcp.event_sources.pop("events_test", None)
def test_a_plugin_adds_a_hostless_resources_events_through_its_hook(app):
async def events(db, source, resource, since, until):
return []
mcp = app.mcp_server
mcp.register_plugin("events_test", NS(mcp_resource_events=events))
try:
assert mcp.resource_event_sources["events_test"] is events
mcp.register_plugin("events_test", NS())
assert "events_test" not in mcp.resource_event_sources
finally:
mcp.resource_event_sources.pop("events_test", None)
def test_steward_not_recording_is_an_event_beside_every_anomaly():
"""A Steward restart between two of its once-a-minute samples (#5862)."""
ticks = [NOW + timedelta(minutes=m) for m in (0, 1, 2)] + [NOW + timedelta(minutes=4, seconds=10)]
[gap] = _events.gaps_in(ticks, NOW, NOW + timedelta(hours=1))
assert gap["kind"] == "steward" and gap["at"] == "2026-10-15T12:02:00Z"
assert gap["until"] == "2026-10-15T12:04:10Z" and "wasn't recording" in gap["what"]
assert _events.gaps_in(ticks, NOW + timedelta(minutes=5), NOW + timedelta(hours=1)) == []
assert _events.gaps_in([NOW + timedelta(minutes=m) for m in range(5)], NOW, NOW + timedelta(hours=1)) == []