#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>
105 lines
4.7 KiB
Python
105 lines
4.7 KiB
Python
"""The monitoring-config write tools: their validation refusals and their roles.
|
|
|
|
Monitor-config validation is covered in tests/core/test_monitors.py (the form
|
|
and the tools share it). The writes themselves, their audit rows and the
|
|
rename warning run against Postgres in
|
|
tests/integration/test_mcp_monitoring_writes.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from steward.alerts import service as alert_svc
|
|
from steward.mcp.tools import alerts
|
|
from steward.models.alerts import AlertOperator
|
|
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", "send_test_alert", "create_maintenance_window",
|
|
"end_maintenance_window"}
|
|
ADMIN_TOOLS = {"delete_monitor", "delete_alert_rule"}
|
|
|
|
|
|
@pytest.fixture
|
|
def known(monkeypatch):
|
|
"""A source with one resource and its catalog metrics, without a database:
|
|
the host_agent plugin is loaded (its get_alert_metrics hook writes the
|
|
catalog) and has reported one host."""
|
|
import plugins.host_agent
|
|
from steward.core import plugin_manager
|
|
monkeypatch.setattr(plugin_manager, "get_loaded_plugin_modules",
|
|
lambda: {"host_agent": plugins.host_agent})
|
|
|
|
async def resources(db, src):
|
|
return ["nas"] if src == "host_agent" else []
|
|
monkeypatch.setattr(alert_svc, "resources_for", resources)
|
|
|
|
|
|
async def _fields(**over):
|
|
args = dict(name="cpu hot", source_module="host_agent", resource_name="nas",
|
|
metric_name="cpu_pct", operator=">", threshold="90", consecutive_failures_required=2)
|
|
args.update(over)
|
|
return await alert_svc.rule_fields(None, **args)
|
|
|
|
|
|
async def test_rule_fields_accepts_the_form_choices(known):
|
|
f = await _fields()
|
|
assert f["operator"] is AlertOperator.gt and f["threshold"] == 90.0
|
|
assert f["consecutive_failures_required"] == 2
|
|
|
|
|
|
@pytest.mark.parametrize("over, why", [
|
|
({"name": " "}, "needs a name"),
|
|
({"source_module": "zabbix"}, "'zabbix' is not one of icmp, tcp"),
|
|
({"resource_name": "web01"}, "host_agent has no resource 'web01'. Known: nas"),
|
|
({"metric_name": "cpu"}, "does not report 'cpu'. Its metrics: cpu_pct"),
|
|
({"operator": "=>"}, "'=>' is not one of >, <"),
|
|
({"threshold": "high"}, "threshold must be a number"),
|
|
({"consecutive_failures_required": 0}, "at least 1"),
|
|
])
|
|
async def test_rule_fields_refuses_with_a_reason(known, over, why):
|
|
with pytest.raises(ValueError, match=why):
|
|
await _fields(**over)
|
|
|
|
|
|
def test_window_fields():
|
|
f = alert_svc.window_fields(name="patching", start_at=NOW, end_at=NOW + timedelta(hours=1),
|
|
scope_source="", scope_resource="nas")
|
|
assert f["scope_source"] is None and f["scope_resource"] is None # resource needs a source
|
|
with pytest.raises(ValueError, match="must end after it starts"):
|
|
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW, scope_source=None,
|
|
scope_resource=None)
|
|
with pytest.raises(ValueError, match="'nope' is not one of"):
|
|
alert_svc.window_fields(name="x", start_at=NOW, end_at=NOW + timedelta(minutes=1),
|
|
scope_source="nope", scope_resource=None)
|
|
|
|
|
|
async def test_maintenance_window_needs_exactly_one_end():
|
|
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
|
|
await alerts.create_maintenance_window("x")
|
|
with pytest.raises(ValueError, match="exactly one of duration_minutes or end_at"):
|
|
await alerts.create_maintenance_window("x", duration_minutes=30, end_at="2026-10-06T13:00:00Z")
|
|
with pytest.raises(ValueError, match="not an ISO-8601 time"):
|
|
await alerts.create_maintenance_window("x", end_at="tonight")
|
|
|
|
|
|
def test_when_reads_a_zoneless_time_as_utc():
|
|
assert alerts._when("2026-10-06T22:00:00", "end_at") == datetime(2026, 10, 6, 22, tzinfo=timezone.utc)
|
|
assert alerts._when("2026-10-06T22:00:00Z", "end_at").utcoffset() == timedelta(0)
|
|
|
|
|
|
def test_write_tools_are_registered_with_their_roles(app):
|
|
mcp = app.mcp_server
|
|
for name in OPERATOR_TOOLS:
|
|
assert mcp.tool_roles[name] is UserRole.operator, name
|
|
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is False, name
|
|
for name in ADMIN_TOOLS:
|
|
assert mcp.tool_roles[name] is UserRole.admin, name
|
|
assert mcp.tool_roles["get_alert_rule_options"] is UserRole.viewer
|
|
for name in OPERATOR_TOOLS | ADMIN_TOOLS | {"get_alert_rule_options"}:
|
|
assert (mcp._tool_manager.get_tool(name).description or "").strip(), name
|