MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks, maintenance". The validation the forms did inline (or not at all) moves to the services, and both the routes and the tools call it, so bad input is refused the same way with the same reason: - monitors/service: monitor_fields, build_config, normalise_target. Port 1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an object of strings, a key the type does not take is named. The forms now answer 400 with the reason instead of redirecting silently or a 500. - alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for, metrics_for, rule_fields (source, resource and metric from the same choices the rule form offers), add_rule, acknowledge, window_fields. Tools: create_monitor, update_monitor (config merged over the stored one; a rename warns about rules still watching the old name), set_monitor_enabled, create_alert_rule, update_alert_rule, acknowledge_alert, create_maintenance_window (duration or end), end_maintenance_window (ends one in progress, cancels an upcoming one) — operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options — viewer. Every write is audited as "<user> via MCP". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
7.7 KiB
Python
147 lines
7.7 KiB
Python
"""Integration: the monitoring-config write tools against real Postgres.
|
|
|
|
A monitor is created on a host, edited (config merged, a rename warning for
|
|
the rule that watches it by name), paused and deleted; a rule is created on
|
|
that monitor, edited, acknowledged once firing, and deleted; a maintenance
|
|
window in progress is ended and an upcoming one cancelled. Every write leaves
|
|
an audit row as "<user> via MCP".
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
_NEEDS_DB = pytest.mark.skipif(
|
|
not os.environ.get("STEWARD_DATABASE_URL"),
|
|
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@_NEEDS_DB
|
|
def test_monitor_rule_ack_and_maintenance_writes(app):
|
|
from sqlalchemy import select
|
|
from steward.core.api_tokens import Caller
|
|
from steward.mcp import _context
|
|
from steward.mcp.tools import alerts as a
|
|
from steward.mcp.tools import monitors as m
|
|
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, MaintenanceWindow
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.hosts import Host
|
|
from steward.models.monitors import Monitor
|
|
from steward.models.users import User, UserRole
|
|
|
|
tag = uuid.uuid4().hex[:8]
|
|
uid, username, host_name = str(uuid.uuid4()), f"mcp-cfg-{tag}", f"cfg-host-{tag}"
|
|
mon_name, rule_name = f"web-{tag}", f"web-down-{tag}"
|
|
|
|
async def _seed():
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
s.add(User(id=uid, username=username, email=f"{tag}@e.test",
|
|
password_hash="x", role=UserRole.admin, is_active=True))
|
|
s.add(Host(id=str(uuid.uuid4()), name=host_name, address="10.7.7.7"))
|
|
|
|
async def _actions():
|
|
async with app.db_sessionmaker() as s:
|
|
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid))).scalars()
|
|
return [(e.action, e.username) for e in rows]
|
|
|
|
async def _go():
|
|
await _seed()
|
|
_context._app_ctx.set(app)
|
|
_context._caller_ctx.set(Caller(user_id=uid, username=username,
|
|
role=UserRole.admin, token_id=7))
|
|
|
|
# ── monitors ───────────────────────────────────────────────────────
|
|
with pytest.raises(ValueError, match="does not take port"):
|
|
await m.create_monitor(mon_name, "http", "nas.lan", config={"port": 80})
|
|
with pytest.raises(ValueError, match="no host named"):
|
|
await m.create_monitor(mon_name, "http", "nas.lan", host=f"ghost-{tag}")
|
|
made = await m.create_monitor(mon_name, "http", "nas.lan", host=host_name,
|
|
config={"expected_status": 204,
|
|
"headers": {"Authorization": "Bearer s3cret"}})
|
|
assert made["target"] == "https://nas.lan" and made["host_name"] == host_name
|
|
assert made["config"]["headers"] == {"Authorization": "<redacted>"}
|
|
assert made["config"]["verify_ssl"] is True
|
|
|
|
# ── rules ──────────────────────────────────────────────────────────
|
|
opts = await a.get_alert_rule_options("http")
|
|
assert mon_name in opts["resources"] and "is_up" in opts["metrics"]
|
|
with pytest.raises(ValueError, match="does not report 'up'"):
|
|
await a.create_alert_rule(rule_name, "http", mon_name, "up", "<", 1)
|
|
rule = await a.create_alert_rule(rule_name, "http", mon_name, "is_up", "<", 1,
|
|
consecutive_checks=3)
|
|
assert rule["condition"] == "is_up < 1 for 3 consecutive checks"
|
|
rule = await a.update_alert_rule(rule_name, threshold=0.5, enabled=False)
|
|
assert rule["condition"].startswith("is_up < 0.5") and rule["enabled"] is False
|
|
|
|
# Editing the config merges: the stored header survives a timeout change.
|
|
upd = await m.update_monitor(mon_name, config={"timeout_seconds": 5})
|
|
assert upd["config"]["timeout_seconds"] == 5 and upd["config"]["expected_status"] == 204
|
|
async with app.db_sessionmaker() as s:
|
|
row = await s.get(Monitor, made["monitor_id"])
|
|
assert row.config["headers"] == {"Authorization": "Bearer s3cret"}
|
|
|
|
# A rename leaves the rule on the old name, and says so.
|
|
renamed = await m.update_monitor(mon_name, name=f"{mon_name}-2")
|
|
assert rule_name in renamed["warning"]
|
|
await a.update_alert_rule(rule_name, resource=f"{mon_name}-2")
|
|
assert "warning" not in await m.update_monitor(f"{mon_name}-2", check_interval_s=30)
|
|
assert (await m.set_monitor_enabled(f"{mon_name}-2", False))["enabled"] is False
|
|
|
|
# ── acknowledge ────────────────────────────────────────────────────
|
|
with pytest.raises(ValueError, match="not firing"):
|
|
await a.acknowledge_alert(rule_name)
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
st = (await s.execute(select(AlertState).where(
|
|
AlertState.rule_id == rule["rule_id"]))).scalar_one()
|
|
st.state = AlertStateEnum.firing
|
|
ack = await a.acknowledge_alert(rule_name)
|
|
assert ack["state"] == "acknowledged"
|
|
|
|
# ── maintenance ────────────────────────────────────────────────────
|
|
win = await a.create_maintenance_window(f"patch-{tag}", duration_minutes=30,
|
|
source_module="http", resource=f"{mon_name}-2")
|
|
assert win["active"] is True
|
|
ended = await a.end_maintenance_window(f"patch-{tag}")
|
|
assert ended["outcome"] == "ended" and ended["active"] is True
|
|
with pytest.raises(ValueError, match="already ended"):
|
|
await a.end_maintenance_window(win["window_id"])
|
|
later = await a.create_maintenance_window(f"later-{tag}", start_at="2099-01-01T00:00:00Z",
|
|
end_at="2099-01-01T02:00:00Z")
|
|
assert (await a.end_maintenance_window(later["window_id"]))["outcome"] == "cancelled"
|
|
async with app.db_sessionmaker() as s:
|
|
assert await s.get(MaintenanceWindow, later["window_id"]) is None
|
|
assert await s.get(MaintenanceWindow, win["window_id"]) is not None
|
|
|
|
# ── deletes ────────────────────────────────────────────────────────
|
|
await a.delete_alert_rule(rule_name)
|
|
await m.delete_monitor(f"{mon_name}-2")
|
|
async with app.db_sessionmaker() as s:
|
|
assert await s.get(AlertRule, rule["rule_id"]) is None
|
|
assert await s.get(Monitor, made["monitor_id"]) is None
|
|
|
|
actions = await _actions()
|
|
assert all(user == f"{username} via MCP" for _, user in actions)
|
|
assert sorted({act for act, _ in actions}) == sorted({
|
|
"monitor.created", "monitor.updated", "monitor.disabled", "monitor.deleted",
|
|
"alert_rule.created", "alert_rule.updated", "alert_rule.acknowledged",
|
|
"alert_rule.deleted", "maintenance_window.created", "maintenance_window.ended",
|
|
"maintenance_window.cancelled"})
|
|
|
|
asyncio.run(_go())
|