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>
This commit is contained in:
+212
-5
@@ -1,12 +1,16 @@
|
||||
"""Alert tools: what is firing, what fired before, the rules, and maintenance windows."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.models.alerts import AlertEvent, AlertRule, AlertState
|
||||
from steward.mcp._context import current_caller
|
||||
from steward.models.alerts import AlertEvent, AlertOperator, AlertRule, AlertState, MaintenanceWindow
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
|
||||
)
|
||||
@@ -184,8 +188,211 @@ async def list_maintenance_windows(include_past: bool = False, limit: int = 50)
|
||||
return bounded(rows, limit, key="windows")
|
||||
|
||||
|
||||
# ── changes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_alert_rule_options(source_module: str = "") -> dict:
|
||||
"""What an alert rule can watch: the choices create_alert_rule accepts.
|
||||
|
||||
source_module: empty lists every source and its metrics; a source's name
|
||||
also lists its resources (hosts, containers, monitors… whatever that
|
||||
source reports under).
|
||||
|
||||
A monitor reports under its type (icmp/tcp/dns/http) with its name as the
|
||||
resource: is_up (1 up, 0 down) and response_ms.
|
||||
"""
|
||||
async with db_session() as db:
|
||||
if not source_module:
|
||||
return {"sources": {src: await alert_svc.metrics_for(db, src)
|
||||
for src in alert_svc.SOURCE_MODULES},
|
||||
"operators": [o.value for o in AlertOperator]}
|
||||
if source_module not in alert_svc.METRIC_CATALOG:
|
||||
raise ValueError(f"source_module {source_module!r} is not one of "
|
||||
f"{', '.join(alert_svc.SOURCE_MODULES)}.")
|
||||
return {"source_module": source_module,
|
||||
"metrics": await alert_svc.metrics_for(db, source_module),
|
||||
"resources": await alert_svc.resources_for(db, source_module),
|
||||
"operators": [o.value for o in AlertOperator]}
|
||||
|
||||
|
||||
async def _rule(db, rule: str) -> AlertRule:
|
||||
return pick(rule, await alert_svc.list_rules(db), kind="alert rule", list_tool="list_alert_rules")
|
||||
|
||||
|
||||
def _rule_row(r: AlertRule) -> dict:
|
||||
return {"rule_id": r.id, "rule_name": r.name, "enabled": r.enabled,
|
||||
"source_module": r.source_module, "resource": r.resource_name,
|
||||
"metric": r.metric_name, "condition": _condition(r), "ansible_action": _action(r)}
|
||||
|
||||
|
||||
async def create_alert_rule(name: str, source_module: str, resource: str, metric: str,
|
||||
operator: str, threshold: float, consecutive_checks: int = 1,
|
||||
enabled: bool = True) -> dict:
|
||||
"""Add an alert rule: notify when `metric` of `resource` crosses `threshold`.
|
||||
|
||||
source_module, resource, metric: get_alert_rule_options lists the valid
|
||||
choices; a resource must have reported (or, for a monitor, exist).
|
||||
operator: ">", "<", ">=", "<=", "==" or "!=".
|
||||
consecutive_checks: how many checks in a row must breach before it fires
|
||||
(default 1); higher values ride out a single blip.
|
||||
|
||||
Notifications go to the channels set in Steward's Settings. The playbook a
|
||||
rule runs when it fires is set separately (set_alert_action, admin).
|
||||
Recorded in the audit log as "<you> via MCP".
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db, db.begin():
|
||||
fields = await alert_svc.rule_fields(
|
||||
db, name=name, source_module=source_module, resource_name=resource,
|
||||
metric_name=metric, operator=operator, threshold=threshold,
|
||||
consecutive_failures_required=consecutive_checks)
|
||||
r = await alert_svc.add_rule(db, fields, created_by=caller.user_id, enabled=enabled)
|
||||
await audit_mcp("alert_rule.created", "alert_rule", r.name,
|
||||
{"rule_id": r.id, "source": r.source_module, "resource": r.resource_name,
|
||||
"metric": r.metric_name})
|
||||
return _rule_row(r)
|
||||
|
||||
|
||||
async def update_alert_rule(rule: str, name: str | None = None, source_module: str | None = None,
|
||||
resource: str | None = None, metric: str | None = None,
|
||||
operator: str | None = None, threshold: float | None = None,
|
||||
consecutive_checks: int | None = None,
|
||||
enabled: bool | None = None) -> dict:
|
||||
"""Change an alert rule. Only the arguments you pass change; the rest stay.
|
||||
|
||||
rule: its name or id (list_alert_rules). Pass enabled=false to silence it
|
||||
without deleting it. The changed rule is validated as create_alert_rule
|
||||
validates a new one. Recorded in the audit log.
|
||||
"""
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
fields = await alert_svc.rule_fields(
|
||||
db,
|
||||
name=r.name if name is None else name,
|
||||
source_module=r.source_module if source_module is None else source_module,
|
||||
resource_name=r.resource_name if resource is None else resource,
|
||||
metric_name=r.metric_name if metric is None else metric,
|
||||
operator=r.operator.value if operator is None else operator,
|
||||
threshold=r.threshold if threshold is None else threshold,
|
||||
consecutive_failures_required=(r.consecutive_failures_required
|
||||
if consecutive_checks is None else consecutive_checks))
|
||||
for k, v in fields.items():
|
||||
setattr(r, k, v)
|
||||
if enabled is not None:
|
||||
r.enabled = bool(enabled)
|
||||
changed = sorted(k for k, v in {"name": name, "source_module": source_module,
|
||||
"resource": resource, "metric": metric, "operator": operator,
|
||||
"threshold": threshold, "consecutive_checks": consecutive_checks,
|
||||
"enabled": enabled}.items() if v is not None)
|
||||
await audit_mcp("alert_rule.updated", "alert_rule", r.name, {"rule_id": r.id, "changed": changed})
|
||||
return _rule_row(r)
|
||||
|
||||
|
||||
async def delete_alert_rule(rule: str) -> dict:
|
||||
"""Delete an alert rule and its history. To silence it but keep it,
|
||||
update_alert_rule(enabled=false). Admin only; recorded in the audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
await db.delete(r)
|
||||
await audit_mcp("alert_rule.deleted", "alert_rule", r.name, {"rule_id": r.id})
|
||||
return {"deleted": r.name, "rule_id": r.id}
|
||||
|
||||
|
||||
async def acknowledge_alert(rule: str) -> dict:
|
||||
"""Acknowledge a firing alert: you have seen it. It stays "acknowledged"
|
||||
while the condition holds and resolves when it clears.
|
||||
|
||||
rule: the firing rule's name or id (list_alerts). Recorded in the audit log.
|
||||
"""
|
||||
caller = current_caller()
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
try:
|
||||
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
|
||||
except LookupError:
|
||||
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
|
||||
"acknowledge.") from None
|
||||
await audit_mcp("alert_rule.acknowledged", "alert_rule", r.name, {"rule_id": r.id})
|
||||
return {"rule_id": r.id, "rule_name": r.name, "state": state.state.value,
|
||||
"acknowledged_at": iso(state.acknowledged_at)}
|
||||
|
||||
|
||||
def _when(value: str, what: str) -> datetime:
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError(f"{what} {value!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _window_row(w: MaintenanceWindow, now: datetime) -> dict:
|
||||
return {"window_id": w.id, "name": w.name, "start_at": iso(w.start_at), "end_at": iso(w.end_at),
|
||||
"active": w.start_at <= now <= w.end_at,
|
||||
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
|
||||
|
||||
|
||||
async def create_maintenance_window(name: str, duration_minutes: int = 0, end_at: str = "",
|
||||
start_at: str = "", source_module: str = "",
|
||||
resource: str = "") -> dict:
|
||||
"""Hold back alert notifications while you work on something.
|
||||
|
||||
Give the end as duration_minutes OR end_at (ISO-8601; a time with no zone
|
||||
is UTC). start_at defaults to now.
|
||||
source_module, resource: limit the window to one source, or to one
|
||||
resource of it; both empty silences everything.
|
||||
|
||||
Alerts still change state during a window, so list_alerts shows them;
|
||||
only notifications are held. Recorded in the audit log.
|
||||
"""
|
||||
now = now_utc()
|
||||
start = _when(start_at, "start_at") if (start_at or "").strip() else now
|
||||
if bool(duration_minutes) == bool((end_at or "").strip()):
|
||||
raise ValueError("give exactly one of duration_minutes or end_at.")
|
||||
if duration_minutes:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError("duration_minutes must be positive.")
|
||||
end = start + timedelta(minutes=duration_minutes)
|
||||
else:
|
||||
end = _when(end_at, "end_at")
|
||||
fields = alert_svc.window_fields(name=name, start_at=start, end_at=end,
|
||||
scope_source=source_module, scope_resource=resource)
|
||||
w = MaintenanceWindow(created_by=current_caller().user_id, **fields)
|
||||
async with db_session() as db, db.begin():
|
||||
db.add(w)
|
||||
await audit_mcp("maintenance_window.created", "maintenance_window", w.name,
|
||||
{"window_id": w.id, "scope": alert_svc.window_scope(w),
|
||||
"start_at": iso(w.start_at), "end_at": iso(w.end_at)})
|
||||
return _window_row(w, now)
|
||||
|
||||
|
||||
async def end_maintenance_window(window: str) -> dict:
|
||||
"""End a maintenance window now, so notifications resume.
|
||||
|
||||
window: its name or id (list_maintenance_windows). A window in progress
|
||||
ends now and stays on record; one that has not started yet is removed.
|
||||
Recorded in the audit log.
|
||||
"""
|
||||
now = now_utc()
|
||||
async with db_session() as db, db.begin():
|
||||
windows = await alert_svc.list_maintenance_windows(db)
|
||||
w = pick(window, windows, kind="maintenance window", list_tool="list_maintenance_windows")
|
||||
if w.end_at <= now:
|
||||
raise ValueError(f"{w.name} already ended at {iso(w.end_at)}.")
|
||||
if w.start_at > now:
|
||||
await db.delete(w)
|
||||
outcome = "cancelled"
|
||||
else:
|
||||
w.end_at = now
|
||||
outcome = "ended"
|
||||
await audit_mcp(f"maintenance_window.{outcome}", "maintenance_window", w.name,
|
||||
{"window_id": w.id})
|
||||
return {**_window_row(w, now), "outcome": outcome}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(list_alerts, role=UserRole.viewer, read_only=True)
|
||||
register_tool(get_alert_history, role=UserRole.viewer, read_only=True)
|
||||
register_tool(list_alert_rules, role=UserRole.viewer, read_only=True)
|
||||
register_tool(list_maintenance_windows, role=UserRole.viewer, read_only=True)
|
||||
for fn in (list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows,
|
||||
get_alert_rule_options):
|
||||
register_tool(fn, role=UserRole.viewer, read_only=True)
|
||||
for fn in (create_alert_rule, update_alert_rule, acknowledge_alert,
|
||||
create_maintenance_window, end_maintenance_window):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(delete_alert_rule, role=UserRole.admin, read_only=False)
|
||||
|
||||
@@ -5,11 +5,13 @@ import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.models.alerts import AlertRule
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
|
||||
from steward.models.users import UserRole
|
||||
from steward.monitors import service as monitor_svc
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
|
||||
)
|
||||
@@ -137,6 +139,138 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── changes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _host_id(db, host: str) -> str | None:
|
||||
if not (host or "").strip():
|
||||
return None
|
||||
hosts = (await db.execute(select(Host))).scalars()
|
||||
return pick(host, hosts, kind="host", list_tool="list_hosts").id
|
||||
|
||||
|
||||
def _saved(m: Monitor, host_names: dict[str, str]) -> dict:
|
||||
return {
|
||||
"monitor_id": m.id,
|
||||
"monitor_name": m.name,
|
||||
"type": m.type,
|
||||
"target": redact_url(m.target),
|
||||
"host_name": host_names.get(m.host_id) if m.host_id else None,
|
||||
"enabled": m.enabled,
|
||||
"check_interval_s": m.check_interval_seconds or None,
|
||||
"config": _config(m),
|
||||
"next": f'get_monitor(monitor="{m.id}")',
|
||||
}
|
||||
|
||||
|
||||
async def create_monitor(name: str, type: str, target: str, host: str = "",
|
||||
config: dict | None = None, check_interval_s: int = 0,
|
||||
enabled: bool = True) -> dict:
|
||||
"""Add an uptime check. Recorded in the audit log as "<you> via MCP".
|
||||
|
||||
type: "icmp" (ping), "tcp" (connect to a port), "dns" (resolve a name)
|
||||
or "http" (request a URL).
|
||||
target: an address or hostname; for http a URL (a bare host gets https://).
|
||||
host: a Steward host's name or id to file the check under; empty for a
|
||||
free-standing destination.
|
||||
config, by type (every key optional, defaults shown):
|
||||
tcp: {"port": 80}
|
||||
dns: {"expected_ip": null} (set to fail when the name resolves elsewhere)
|
||||
http: {"method": "GET" | "HEAD" | "POST", "expected_status": 200,
|
||||
"content_match": "", "headers": {}, "timeout_seconds": 10 (1-60),
|
||||
"follow_redirects": true, "verify_ssl": true}
|
||||
icmp takes none.
|
||||
check_interval_s: seconds between checks; 0 (default) uses the global
|
||||
interval from Settings.
|
||||
|
||||
The check's metrics are reported under source_module = its type and
|
||||
resource = its name, which is what an alert rule on it watches.
|
||||
"""
|
||||
fields = monitor_svc.monitor_fields(type, target=target, name=name,
|
||||
check_interval_seconds=check_interval_s, opts=config or {})
|
||||
async with db_session() as db, db.begin():
|
||||
m = Monitor(host_id=await _host_id(db, host), enabled=enabled, **fields)
|
||||
db.add(m)
|
||||
_monitors, host_names = await _load(db)
|
||||
await audit_mcp("monitor.created", "monitor", m.name,
|
||||
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
|
||||
return _saved(m, host_names)
|
||||
|
||||
|
||||
async def update_monitor(monitor: str, name: str | None = None, target: str | None = None,
|
||||
host: str | None = None, config: dict | None = None,
|
||||
check_interval_s: int | None = None) -> dict:
|
||||
"""Change a monitor. Only the arguments you pass change; the rest stay.
|
||||
|
||||
monitor: its name or id (list_monitors).
|
||||
host: a host's name or id to file it under, or "" to unlink it.
|
||||
config: the keys to change, merged over its current config (see
|
||||
create_monitor for the keys of each type). A monitor's type cannot
|
||||
change; delete it and create another.
|
||||
|
||||
Alert rules watch a monitor by its name, so a rename returns `warning`
|
||||
naming the rules still on the old name.
|
||||
|
||||
Recorded in the audit log.
|
||||
"""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
current = {k: v for k, v in m.config.items() if k in monitor_svc.CONFIG_KEYS[m.type]}
|
||||
fields = monitor_svc.monitor_fields(
|
||||
m.type, target=m.target if target is None else target,
|
||||
name=m.name if name is None else name,
|
||||
check_interval_seconds=(m.check_interval_seconds if check_interval_s is None
|
||||
else check_interval_s),
|
||||
opts={**current, **(config or {})})
|
||||
old_name = m.name
|
||||
for k, v in fields.items():
|
||||
setattr(m, k, v)
|
||||
if host is not None:
|
||||
m.host_id = await _host_id(db, host)
|
||||
stranded = []
|
||||
if m.name != old_name:
|
||||
stranded = list((await db.execute(
|
||||
select(AlertRule.name).where(AlertRule.source_module == m.type,
|
||||
AlertRule.resource_name == old_name))).scalars())
|
||||
changed = sorted(k for k, v in {"name": name, "target": target, "host": host, "config": config,
|
||||
"check_interval_s": check_interval_s}.items() if v is not None)
|
||||
await audit_mcp("monitor.updated", "monitor", m.name, {"monitor_id": m.id, "changed": changed})
|
||||
out = _saved(m, host_names)
|
||||
if stranded:
|
||||
out["warning"] = (f"Alert rules watch this monitor by name and still watch {old_name!r}: "
|
||||
f"{', '.join(stranded)}. update_alert_rule(resource=\"{m.name}\") "
|
||||
"points each at the new name.")
|
||||
return out
|
||||
|
||||
|
||||
async def set_monitor_enabled(monitor: str, enabled: bool) -> dict:
|
||||
"""Pause (enabled=false) or resume a monitor. A paused monitor runs no checks
|
||||
and raises no alerts. Recorded in the audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
m.enabled = bool(enabled)
|
||||
await audit_mcp("monitor.enabled" if m.enabled else "monitor.disabled", "monitor", m.name,
|
||||
{"monitor_id": m.id})
|
||||
return _saved(m, host_names)
|
||||
|
||||
|
||||
async def delete_monitor(monitor: str) -> dict:
|
||||
"""Delete a monitor and its check history. To stop it but keep its history,
|
||||
set_monitor_enabled(enabled=false) instead. Admin only; recorded in the
|
||||
audit log."""
|
||||
async with db_session() as db, db.begin():
|
||||
monitors, _host_names = await _load(db)
|
||||
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
||||
await db.delete(m)
|
||||
await audit_mcp("monitor.deleted", "monitor", m.name,
|
||||
{"monitor_id": m.id, "type": m.type, "target": redact_url(m.target)})
|
||||
return {"deleted": m.name, "monitor_id": m.id}
|
||||
|
||||
|
||||
def register(register_tool) -> None:
|
||||
register_tool(list_monitors, role=UserRole.viewer, read_only=True)
|
||||
register_tool(get_monitor, role=UserRole.viewer, read_only=True)
|
||||
for fn in (create_monitor, update_monitor, set_monitor_enabled):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(delete_monitor, role=UserRole.admin, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user