A redeploy's new process ticks at once, so its once-a-minute samples never broke for 90 s and get_anomaly showed none of the day's restarts. The first self-monitor tick in a process now records restart_gap_secs (time since the last process's last sample), and steward_gaps shows each one as a restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
7.3 KiB
Python
155 lines
7.3 KiB
Python
"""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_a_busy_window_keeps_the_events_nearest_the_occurrence_and_counts_the_rest():
|
||
"""#5861: 50 container events over an hour; the occurrence starts at :45."""
|
||
evs = [_events.event(NOW + timedelta(minutes=m), "container", f"c{m} start") for m in range(50)]
|
||
kept, cut = _events.nearest(evs, NOW + timedelta(minutes=45))
|
||
assert len(kept) == 40 and cut == 10
|
||
assert {e["what"] for e in kept} >= {"c45 start", "c49 start", "c20 start"}
|
||
assert "c0 start" not in {e["what"] for e in kept}
|
||
assert _events.nearest(evs[:5], NOW) == (evs[:5], 0)
|
||
|
||
|
||
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)) == []
|
||
|
||
|
||
def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
|
||
"""A redeploy's new process ticks at once, so the samples stay under a
|
||
minute apart; its first tick's restart_gap_secs marks it (#5862)."""
|
||
ticks = [NOW, NOW + timedelta(seconds=60), NOW + timedelta(seconds=95), NOW + timedelta(seconds=155)]
|
||
restart = NOW + timedelta(seconds=95)
|
||
[ev] = _events.gaps_in(ticks, NOW, NOW + timedelta(hours=1), [(restart, 35.0)])
|
||
assert ev["kind"] == "steward" and "restarted" in ev["what"]
|
||
assert (ev["at"], ev["until"]) == ("2026-10-15T12:01:00Z", "2026-10-15T12:01:35Z")
|
||
# A long restart is one event, not a restart and a gap.
|
||
slow = NOW + timedelta(minutes=5)
|
||
[ev] = _events.gaps_in([NOW, slow], NOW, NOW + timedelta(hours=1), [(slow, 300.0)])
|
||
assert "restarted" in ev["what"]
|
||
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
|