Files
FabledSteward/tests/integration/test_mcp_monitoring_writes.py
T
bvandeusenandClaude Opus 5.5 9abe0beecc
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 41s
CI / unit (push) Successful in 1m0s
CI / publish (push) Successful in 58s
feat(mcp): monitoring-config write tools — monitors, alert rules, acks, maintenance
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>
2026-10-06 08:19:54 -04:00

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())