Files
FabledSteward/tests/integration/test_mcp_monitoring_writes.py
T
bvandeusenandClaude Opus 5.5 c9f5c049db
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 50s
CI / unit (push) Successful in 1m6s
CI / publish (push) Successful in 1m2s
feat(alerts): renames carry their alerts — monitor and host renames move rules, windows and history (#5065)
#5065 "Renames carry their alerts". Alert rules, maintenance-window scopes
and metric history key a thing by (source, name), so a rename used to leave
the rules watching a name nothing reported under, and split the history.

- core/renames.carry_rename moves AlertRule.resource_name,
  MaintenanceWindow.scope_resource, plugin_metrics and plugin_metrics_hourly
  to the new name in the caller's transaction. A host rename also carries
  its parts: host_agent "<host>:…" and docker "<host>/…" (HOST_SOURCES).
  Matching is exact (a prefix compare, not LIKE or a collation range).
  History left under the new name by something deleted is dropped first
  rather than merged in.
- Web monitor edit, MCP update_monitor and web host edit call it; MCP
  answers with `carried` (it used to return a warning naming stranded rules).
- A name another monitor of the same type, or another host, already has is
  refused on create and rename: two would share one history.
- The rename is audited (monitor.updated / host.renamed) with what moved.

Tests: an integration test renames a firing monitor through the web route
and finds its rule still firing and cleared by a sample on the new name,
its window and raw+hourly history moved and an orphan's history dropped; a
host rename moves agent and container parts but not a longer-named host's
or another source's; a guard that every host_agent resource is "<host>" or
"<host>:…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:02:43 -04:00

151 lines
8.1 KiB
Python

"""Integration: the monitoring-config write tools against real Postgres.
A monitor is created on a host, edited (config merged, a rename that carries
the rule watching 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 takes the rule with it, and says what moved.
renamed = await m.update_monitor(mon_name, name=f"{mon_name}-2")
assert renamed["carried"]["from"] == mon_name and renamed["carried"]["rules"] == 1
async with app.db_sessionmaker() as s:
assert (await s.get(AlertRule, rule["rule_id"])).resource_name == f"{mon_name}-2"
assert "carried" not in await m.update_monitor(f"{mon_name}-2", check_interval_s=30)
# Another http monitor may not take the same name: they would share one history.
with pytest.raises(ValueError, match="already named"):
await m.create_monitor(f"{mon_name}-2", "http", "other.lan")
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())