#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>
151 lines
8.1 KiB
Python
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())
|