Files
FabledSteward/tests/integration/test_mcp_anomaly_review.py
T
bvandeusenandClaude Opus 5.5 2b97c36fd4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
feat(timeline): maintenance, reboots, deploys, crash-loops and reviewed anomalies on the timeline (#6349)
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.

A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.

get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:21:44 -04:00

185 lines
10 KiB
Python

"""Integration (#5837): understanding and judging a metric anomaly through
MCP, against real Postgres.
A host's CPU anomaly with two occurrences. Around the newest one, the host
had an alert, a container death, a reboot, an Ansible run and a memory
anomaly; an earlier anomaly on the same metric was marked normal with a note.
get_anomaly brings all of that back; review_anomaly marks two at once;
get_metric_normal reads the learned slots; set_anomaly_settings changes a
floor and the review window, and the running app follows.
Plugin rows are seeded with plain SQL (a second import of a plugin's models
raises "Table already defined").
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_an_anomaly_with_its_circumstances_judged_through_mcp(app):
from sqlalchemy import select, text
from steward.core.api_tokens import Caller
from steward.core.norms import slot_of
from steward.mcp import _context
from steward.mcp.tools import anomalies as tools
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.audit import AuditEvent
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.norms import MetricAnomaly, MetricAnomalySpan, MetricNorm
from steward.models.users import User, UserRole
from steward.timeline.record import from_alert, from_run, record
tag = uuid.uuid4().hex[:8]
host_name, other_host = f"an-{tag}", f"an-other-{tag}"
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
new_start, new_end = now - timedelta(hours=3), now - timedelta(hours=2)
old_start, old_end = now - timedelta(hours=26), now - timedelta(hours=25, minutes=30)
ids = {}
def anomaly(resource, metric, start, end, **kw):
return MetricAnomaly(source_module="host_agent", resource_name=resource, metric_name=metric,
started_at=start, last_started_at=start, ended_at=end,
occurrences=kw.pop("occurrences", 1), peak_level=kw.pop("peak", 90.0), peak_at=start, usual_hold=20.0,
usual_peak=35.0, **kw)
async def _seed():
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"an-{tag}", email=f"an-{tag}@test", password_hash="x",
role=UserRole.admin)
host, other = Host(name=host_name, address="10.9.9.7"), Host(name=other_host, address="10.9.9.8")
s.add_all([user, host, other])
await s.flush()
ids.update(user=user.id, host=host.id)
cpu = anomaly(host_name, "cpu_pct", old_start, new_end, occurrences=2, peak=93.0)
cpu.last_started_at = new_start
before = anomaly(host_name, "cpu_pct", now - timedelta(days=9), now - timedelta(days=9, hours=-1),
review="normal", reviewed_by="op", note="nightly backup", peak=70.0)
mem = anomaly(host_name, "mem_used_pct", new_start + timedelta(minutes=10), new_end, peak=95.0)
far = anomaly(other_host, "cpu_pct", new_start, new_end)
s.add_all([cpu, before, mem, far])
await s.flush()
ids.update(cpu=cpu.id, before=before.id, mem=mem.id)
s.add_all([
MetricAnomalySpan(anomaly_id=cpu.id, started_at=old_start, ended_at=old_end, peak_level=88.0),
MetricAnomalySpan(anomaly_id=cpu.id, started_at=new_start, ended_at=new_end, peak_level=93.0),
MetricAnomalySpan(anomaly_id=mem.id, started_at=new_start + timedelta(minutes=10),
ended_at=new_end, peak_level=95.0),
MetricAnomalySpan(anomaly_id=far.id, started_at=new_start, ended_at=new_end, peak_level=90.0),
MetricNorm(source_module="host_agent", resource_name=host_name, metric_name="cpu_pct",
slot=slot_of(new_start, "day"), kind="day", usual_hold=20.0, usual_peak=35.0,
days_seen=12, updated_at=now),
])
for minutes in range(-240, -60, 5):
at = now + timedelta(minutes=minutes)
s.add(PluginMetric(source_module="host_agent", resource_name=host_name,
metric_name="cpu_pct", recorded_at=at,
value=90.0 if new_start <= at < new_end else 18.0))
rule = AlertRule(name=f"CPU high {tag}", source_module="host_agent", resource_name=host_name,
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=85,
created_by=user.id)
s.add(rule)
await s.flush()
fired = AlertEvent(rule_id=rule.id, from_state="ok", to_state="firing", metric_value=91.0,
transitioned_at=new_start + timedelta(minutes=20))
run = AnsibleRun(playbook_path="maintenance/backup.yml", source_name="steward-builtin",
status=AnsibleRunStatus.success, started_at=new_start - timedelta(minutes=10),
finished_at=new_start + timedelta(minutes=5), params={"check": False},
results={"hosts": {host_name: {"ok": 3}}, "failures": []})
s.add_all([fired, run])
await s.flush()
# The timeline, as the alert, the run and the plugins write it.
await from_alert(s, rule, fired)
await from_run(s, run)
record(s, type="docker.restarted", at=new_start + timedelta(minutes=15),
summary=f"web on {host_name}: died (exit 137)", hosts=[host_name],
subject=f"{host_name}/web")
record(s, type="host.rebooted", at=new_end - timedelta(minutes=5),
until=new_end + timedelta(minutes=1), summary=f"{host_name}: rebooted for 6m",
hosts=[host_name], subject=host_name)
# A container event the timeline doesn't hold, from Docker's own source.
await s.execute(text(
"INSERT INTO docker_events (id, host_id, container_name, event, detail, at) "
"VALUES (:id, :h, 'web', 'health_change', 'healthy→unhealthy', :at)"),
{"id": str(uuid.uuid4()), "h": host.id, "at": new_start + timedelta(minutes=16)})
async def _go():
await _seed()
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=ids["user"], username=f"an-{tag}",
role=UserRole.admin, token_id=9))
got = await tools.get_anomaly(ids["cpu"])
assert got["place"] == {"host": host_name, "host_id": ids["host"]}
assert got["occurrences_total"] == 2 and got["truncated"] is False
newest, oldest = got["occurrences"]
assert newest["held_s"] == 3600 and newest["peak_level"] == 93.0
assert newest["usual"]["usual_hold"] == 20.0 and newest["usual"]["flag_above"] >= 25.0
kinds = {e["kind"] for e in newest["around"]}
assert {"alert.fired", "ansible.unchanged", "docker.restarted", "host.rebooted", "container",
"anomaly"} <= kinds
whats = " | ".join(e["what"] for e in newest["around"])
assert "died (exit 137)" in whats and "maintenance/backup.yml" in whats
assert "web health_change (healthy→unhealthy)" in whats
assert any(e["kind"] == "anomaly" and e["ids"]["anomaly_id"] == ids["mem"] for e in newest["around"])
assert newest["anomalies_elsewhere"] >= 1
assert not [e for e in oldest["around"] if e["kind"] in ("container", "ansible.unchanged")]
points = got["series"]["points"]
assert max(p[1] for p in points) == 90.0 and min(p[1] for p in points) == 18.0
assert any(p[2] == 20.0 for p in points)
[prior] = got["precedent"]
assert prior["anomaly_id"] == ids["before"] and prior["note"] == "nightly backup"
normal = await tools.get_metric_normal("host_agent", host_name, "cpu_pct")
assert normal["learned"] is True and normal["kind"] == "day"
assert normal["floor_setting"] == "host_cpu_pct"
[slot] = normal["slots"]
assert slot["usual_hold"] == 20.0 and slot["label"].endswith(":00 UTC")
with pytest.raises(ValueError, match="doesn't learn a normal"):
await tools.get_metric_normal("host_agent", host_name, "nope_pct")
with pytest.raises(ValueError, match="Nothing was marked"):
await tools.review_anomaly([ids["cpu"], "no-such-id"], "real")
marked = await tools.review_anomaly([ids["cpu"], ids["mem"]], "real", note="the backup job hung")
assert {m["anomaly_id"] for m in marked["marked"]} == {ids["cpu"], ids["mem"]}
assert marked["reviewed_by"] == f"an-{tag} via MCP"
try:
changed = await tools.set_anomaly_settings(review_days=21, floors={"host_cpu_pct": 15})
assert changed["review_days"] == 21
floor = {f["name"]: f for f in changed["floors"]}["host_cpu_pct"]
assert floor["value"] == 15.0 and "host_agent/cpu_pct" in floor["metrics"]
assert app.config["THRESHOLDS"]["floors"]["host_cpu_pct"] == 15.0
finally:
await tools.set_anomaly_settings(review_days=14, floors={"host_cpu_pct": 10})
async with app.db_sessionmaker() as s:
audits = list((await s.execute(select(AuditEvent.action).where(
AuditEvent.user_id == ids["user"]).order_by(AuditEvent.timestamp))).scalars())
rows = {a.id: a for a in (await s.execute(select(MetricAnomaly).where(
MetricAnomaly.id.in_([ids["cpu"], ids["mem"]])))).scalars()}
assert audits.count("metric_anomaly.marked_real") == 2 and audits.count("settings.saved") == 2
assert {a.review for a in rows.values()} == {"real"}
assert {a.note for a in rows.values()} == {"the backup job hung"}
asyncio.run(_go())