feat(alerts): Send test on a rule checks that its alerts reach someone (#5466)
#5466 "Send test: check a rule's delivery from the rule". A rule's page gets a Send test button (operator role), and MCP gets send_test_alert. Each sends a TEST alert for that rule now, by email to everyone who gets alerts and by webhook. The alert carries the rule's name and condition, at the latest reading of what it watches; a rule covering hosts uses its first host. The result for each channel shows under the button, or "Nothing is set up to send to". Nothing fires, no event is written, and the test is audited as alert_rule.tested. An alert with no reading now says "has no reading yet" instead of "is None". Docs: alerting.md also gains the burst email from #5465 "One email per burst of alerts". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""What a rule's Send test carries (#5466): the rule's own name and
|
||||
condition, marked TEST, on its resource at the latest reading; a rule
|
||||
covering hosts tests on its first host."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.models.alerts import AlertOperator
|
||||
|
||||
|
||||
def _rule(**kw):
|
||||
base = dict(id="r1", name="Hot", source_module="host_agent", resource_name="web01",
|
||||
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=90.0,
|
||||
covers="resource", cover_group=None, parent_id=None)
|
||||
return SimpleNamespace(**{**base, **kw})
|
||||
|
||||
|
||||
class _Db:
|
||||
"""Answers the one query test_alert makes: a covering rule's first host's rule."""
|
||||
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
|
||||
async def execute(self, stmt):
|
||||
return SimpleNamespace(scalar_one_or_none=lambda: self.child)
|
||||
|
||||
|
||||
def _readings(monkeypatch, values):
|
||||
async def latest(db, rule):
|
||||
return values.get(rule.resource_name)
|
||||
monkeypatch.setattr(alert_svc, "latest_value", latest)
|
||||
|
||||
|
||||
async def test_a_rule_tests_on_its_own_resource_at_its_latest_reading(monkeypatch):
|
||||
_readings(monkeypatch, {"web01": 42.0})
|
||||
got = await alert_svc.test_alert(_Db(None), _rule())
|
||||
assert {k: got[k] for k in ("rule_name", "state", "resource", "value", "operator", "threshold")} == {
|
||||
"rule_name": "Hot", "state": "TEST", "resource": "web01", "value": 42.0,
|
||||
"operator": ">", "threshold": 90.0}
|
||||
|
||||
|
||||
async def test_a_covering_rule_tests_on_its_first_host(monkeypatch):
|
||||
_readings(monkeypatch, {"db01": 12.0})
|
||||
parent = _rule(resource_name="", covers="group", cover_group="web")
|
||||
got = await alert_svc.test_alert(_Db(_rule(id="c1", name="Hot: db01", resource_name="db01",
|
||||
parent_id="r1")), parent)
|
||||
assert (got["rule_name"], got["resource"], got["value"]) == ("Hot", "db01", 12.0)
|
||||
|
||||
|
||||
async def test_a_covering_rule_with_no_hosts_yet_says_what_it_covers(monkeypatch):
|
||||
_readings(monkeypatch, {})
|
||||
got = await alert_svc.test_alert(_Db(None), _rule(resource_name="", covers="all_hosts"))
|
||||
assert (got["resource"], got["value"]) == ("every host", None)
|
||||
@@ -96,11 +96,13 @@ def test_without_steward_s_address_there_is_no_link_and_utc_is_said_once():
|
||||
|
||||
def test_each_state_reads_as_a_sentence():
|
||||
head = {s: mail.alert_facts({**ALERT, "state": s, "value": v}, timezone.utc)["headline"]
|
||||
for s, v in (("FIRING", 93.5), ("RESOLVED", 71.0), ("NO_DATA", None), ("DATA_RESUMED", 80.0))}
|
||||
for s, v in (("FIRING", 93.5), ("RESOLVED", 71.0), ("NO_DATA", None), ("DATA_RESUMED", 80.0),
|
||||
("TEST", None))}
|
||||
assert head == {"FIRING": "Disk used on web01 is 94%.",
|
||||
"RESOLVED": "Disk used on web01 is back within its limit, at 71%.",
|
||||
"NO_DATA": "Steward has had no disk used reading from web01.",
|
||||
"DATA_RESUMED": "web01 is reporting disk used again."}
|
||||
"DATA_RESUMED": "web01 is reporting disk used again.",
|
||||
"TEST": "Disk used on web01 has no reading yet."}
|
||||
down = mail.alert_facts({**ALERT, "metric": "is_up", "operator": "<", "threshold": 0.5, "value": 0.0},
|
||||
timezone.utc)
|
||||
assert (down["headline"], down["condition"]) == ("web01 is down.", "is down")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Integration: Send test on an alert rule (#5466).
|
||||
|
||||
The button on a rule's page, and send_test_alert over MCP, send a TEST alert
|
||||
for that rule now, by every channel a real alert of it would use, and say
|
||||
what happened to each. Nothing fires and the rule's history is unchanged;
|
||||
the audit log records the test. Only the delivery itself is replaced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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_send_test_reaches_each_channel_and_leaves_the_rule_alone(app, monkeypatch):
|
||||
from sqlalchemy import func, select
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.core import mail, notifications
|
||||
from steward.core.alerts import record_metric
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.mcp.tools import alerts as mcp_alerts
|
||||
from steward.models.alerts import AlertEvent, AlertState, AlertStateEnum
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
emails, hooks = [], []
|
||||
|
||||
async def fake_email(cfg, alert, to):
|
||||
emails.append((alert, [r.address for r in to]))
|
||||
return {"sent": True, "error": None}
|
||||
|
||||
async def fake_hook(cfg, alert):
|
||||
hooks.append(alert)
|
||||
return {"sent": False, "error": "connection refused"}
|
||||
|
||||
async def fake_recipients(db, kind, smtp):
|
||||
return [mail.Recipient("ann@example.com", ZoneInfo("UTC"), False, "ann")]
|
||||
|
||||
monkeypatch.setattr(notifications, "_send_email", fake_email)
|
||||
monkeypatch.setattr(notifications, "_send_webhook", fake_hook)
|
||||
monkeypatch.setattr(mail, "recipients", fake_recipients)
|
||||
app.config["SMTP"] = {"host": "mx.example", "port": 587, "from": "alerts@example.com"}
|
||||
app.config["WEBHOOK"] = {"url": "https://hook.example/api/1"}
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
host = f"st-{tag}"
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
user = User(username=f"st-{tag}", email=f"st-{tag}@test", password_hash="x",
|
||||
role=UserRole.operator, is_active=True)
|
||||
s.add(user)
|
||||
await s.flush()
|
||||
await record_metric(s, "host_agent", host, "cpu_pct", 42.0)
|
||||
fields = await alert_svc.rule_fields(
|
||||
s, name=f"Hot {tag}", source_module="host_agent", resource_name=host,
|
||||
metric_name="cpu_pct", operator=">", threshold=90)
|
||||
rule = await alert_svc.add_rule(s, fields, created_by=user.id)
|
||||
uid, rule_id = user.id, rule.id
|
||||
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role="operator", username=f"st-{tag}",
|
||||
_csrf="test-token", login_at=time.time())
|
||||
resp = await client.post(f"/alerts/rules/{rule_id}/test",
|
||||
headers={"X-CSRF-Token": "test-token"})
|
||||
assert resp.status_code == 200
|
||||
page = await resp.get_data(as_text=True)
|
||||
assert "alert-error" in page
|
||||
assert "Email sent to ann@example.com." in page
|
||||
assert "Webhook to hook.example failed: connection refused" in page
|
||||
|
||||
# What went out is this rule, marked TEST, at its latest reading.
|
||||
((alert, to),) = emails
|
||||
assert to == ["ann@example.com"] and hooks == [alert]
|
||||
assert (alert["rule_name"], alert["state"], alert["resource"], alert["value"]) == (
|
||||
f"Hot {tag}", "TEST", host, 42.0)
|
||||
|
||||
# Nothing fired: no event, the alert still inactive. The audit log has it.
|
||||
async with app.db_sessionmaker() as s:
|
||||
events = (await s.execute(select(func.count()).select_from(AlertEvent)
|
||||
.where(AlertEvent.rule_id == rule_id))).scalar_one()
|
||||
state = (await s.execute(select(AlertState).where(
|
||||
AlertState.rule_id == rule_id))).scalar_one()
|
||||
audits = (await s.execute(select(AuditEvent).where(
|
||||
AuditEvent.user_id == uid))).scalars().all()
|
||||
assert events == 0 and state.state == AlertStateEnum.inactive
|
||||
assert [a.action for a in audits] == ["alert_rule.tested"]
|
||||
assert json.loads(audits[0].detail_json)["ok"] is False
|
||||
|
||||
# Over MCP, with nothing set up to send to, it says so.
|
||||
app.config["SMTP"], app.config["WEBHOOK"] = {}, {}
|
||||
_context._app_ctx.set(app)
|
||||
_context._caller_ctx.set(Caller(user_id=uid, username=f"st-{tag}",
|
||||
role=UserRole.operator, token_id=7))
|
||||
got = await mcp_alerts.send_test_alert(f"Hot {tag}")
|
||||
assert got["ok"] is False and got["result"][0].startswith("Nothing is set up to send to")
|
||||
assert len(emails) == 1 and len(hooks) == 1
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -19,7 +19,7 @@ from steward.models.users import UserRole
|
||||
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
OPERATOR_TOOLS = {"create_monitor", "update_monitor", "set_monitor_enabled", "create_alert_rule",
|
||||
"update_alert_rule", "acknowledge_alert", "create_maintenance_window",
|
||||
"update_alert_rule", "acknowledge_alert", "send_test_alert", "create_maintenance_window",
|
||||
"end_maintenance_window"}
|
||||
ADMIN_TOOLS = {"delete_monitor", "delete_alert_rule"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user