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:
+29
-41
@@ -5,7 +5,6 @@ type-aware add/edit form, one dashboard widget. A monitor may be linked to a
|
||||
Host (host_id) or watch a free-standing custom destination (host_id = NULL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -20,35 +19,24 @@ from steward.monitors import service as monitor_svc
|
||||
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
|
||||
|
||||
|
||||
def _build_config(form, mtype: str) -> dict:
|
||||
"""Assemble the type-specific config dict from submitted form fields."""
|
||||
def _form_opts(form, mtype: str) -> dict:
|
||||
"""The submitted fields of `mtype`'s config, for monitor_svc.build_config.
|
||||
An unchecked checkbox is absent from the form, so it reads as False."""
|
||||
if mtype == MonitorType.tcp.value:
|
||||
return {"port": int(form.get("port") or 80)}
|
||||
return {"port": form.get("port")}
|
||||
if mtype == MonitorType.dns.value:
|
||||
return {"expected_ip": (form.get("expected_ip", "").strip() or None)}
|
||||
return {"expected_ip": form.get("expected_ip")}
|
||||
if mtype == MonitorType.http.value:
|
||||
try:
|
||||
headers = json.loads(form.get("headers_json") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
headers = {}
|
||||
return {
|
||||
"method": form.get("method", "GET").upper(),
|
||||
"expected_status": int(form.get("expected_status", 200) or 200),
|
||||
"content_match": form.get("content_match", "").strip(),
|
||||
"headers": headers,
|
||||
"timeout_seconds": int(form.get("timeout_seconds", 10) or 10),
|
||||
"method": form.get("method"),
|
||||
"expected_status": form.get("expected_status"),
|
||||
"content_match": form.get("content_match"),
|
||||
"headers": form.get("headers_json"),
|
||||
"timeout_seconds": form.get("timeout_seconds"),
|
||||
"follow_redirects": "follow_redirects" in form,
|
||||
"verify_ssl": "verify_ssl" in form,
|
||||
}
|
||||
return {} # icmp
|
||||
|
||||
|
||||
def _normalise_target(mtype: str, target: str) -> str:
|
||||
"""For http, default a bare host to https://; others pass through."""
|
||||
target = target.strip()
|
||||
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
|
||||
return "https://" + target
|
||||
return target
|
||||
return {}
|
||||
|
||||
|
||||
@monitors_bp.get("/")
|
||||
@@ -80,22 +68,17 @@ async def rows():
|
||||
async def add_monitor():
|
||||
form = await request.form
|
||||
mtype = form.get("type", "").strip()
|
||||
target = _normalise_target(mtype, form.get("target", ""))
|
||||
if mtype not in MONITOR_TYPE_VALUES or not target:
|
||||
return redirect(url_for("monitors.index"))
|
||||
try:
|
||||
fields = monitor_svc.monitor_fields(
|
||||
mtype, target=form.get("target", ""), name=form.get("name", ""),
|
||||
check_interval_seconds=form.get("check_interval_seconds"),
|
||||
opts=_form_opts(form, mtype))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
|
||||
host_id = form.get("host_id", "").strip() or None
|
||||
monitor = Monitor(
|
||||
id=str(uuid.uuid4()),
|
||||
name=form.get("name", "").strip() or target,
|
||||
type=mtype,
|
||||
target=target,
|
||||
host_id=host_id,
|
||||
config_json=json.dumps(_build_config(form, mtype)),
|
||||
enabled=True,
|
||||
check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
monitor = Monitor(id=str(uuid.uuid4()), host_id=host_id, enabled=True,
|
||||
created_at=datetime.now(timezone.utc), **fields)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(monitor)
|
||||
@@ -129,10 +112,15 @@ async def edit_monitor(monitor_id: str):
|
||||
return redirect(url_for("monitors.index"))
|
||||
# Type is immutable on edit (changes the meaning of config/target);
|
||||
# to switch type, delete and re-add.
|
||||
m.name = form.get("name", "").strip() or m.target
|
||||
m.target = _normalise_target(m.type, form.get("target", m.target))
|
||||
m.config_json = json.dumps(_build_config(form, m.type))
|
||||
m.check_interval_seconds = int(form.get("check_interval_seconds", 0) or 0)
|
||||
try:
|
||||
fields = monitor_svc.monitor_fields(
|
||||
m.type, target=form.get("target", m.target), name=form.get("name", ""),
|
||||
check_interval_seconds=form.get("check_interval_seconds"),
|
||||
opts=_form_opts(form, m.type))
|
||||
except ValueError as exc:
|
||||
return str(exc), 400
|
||||
for k, v in fields.items():
|
||||
setattr(m, k, v)
|
||||
return redirect(url_for("monitors.index"))
|
||||
|
||||
|
||||
|
||||
+104
-5
@@ -1,18 +1,117 @@
|
||||
"""Monitor queries shared by the monitors pages and the MCP tools.
|
||||
"""Monitor queries and validation shared by the monitors pages and the MCP tools.
|
||||
|
||||
Each function takes an open session, with no Quart request context, so the
|
||||
routes and the /mcp tools (which run outside Quart's pipeline) call the same
|
||||
code.
|
||||
Each function takes an open session (or plain values), with no Quart request
|
||||
context, so the routes and the /mcp tools (which run outside Quart's
|
||||
pipeline) call the same code and refuse the same input for the same reasons.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
|
||||
from steward.core.time_range import parse_range
|
||||
from steward.models.monitors import Monitor, MonitorResult
|
||||
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
|
||||
|
||||
HTTP_METHODS = ("GET", "HEAD", "POST")
|
||||
# The config keys each type takes (models/monitors.py documents their meaning).
|
||||
CONFIG_KEYS: dict[str, tuple[str, ...]] = {
|
||||
MonitorType.icmp.value: (),
|
||||
MonitorType.tcp.value: ("port",),
|
||||
MonitorType.dns.value: ("expected_ip",),
|
||||
MonitorType.http.value: ("method", "expected_status", "content_match", "headers",
|
||||
"timeout_seconds", "follow_redirects", "verify_ssl"),
|
||||
}
|
||||
|
||||
|
||||
def _whole(name: str, value, default: int, lo: int, hi: int | None = None) -> int:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f"{name} must be a whole number, got {value!r}.")
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{name} must be a whole number, got {value!r}.") from None
|
||||
if n < lo or (hi is not None and n > hi):
|
||||
bounds = f"between {lo} and {hi}" if hi is not None else f"at least {lo}"
|
||||
raise ValueError(f"{name} must be {bounds}, got {n}.")
|
||||
return n
|
||||
|
||||
|
||||
def _headers(value) -> dict:
|
||||
if value is None or value == "":
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
raise ValueError("headers must be a JSON object of name: value strings.") from None
|
||||
if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str)
|
||||
for k, v in value.items()):
|
||||
raise ValueError("headers must be an object of name: value strings.")
|
||||
return value
|
||||
|
||||
|
||||
def normalise_target(mtype: str, target: str) -> str:
|
||||
"""For http, default a bare host to https://; others pass through."""
|
||||
target = (target or "").strip()
|
||||
if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")):
|
||||
return "https://" + target
|
||||
return target
|
||||
|
||||
|
||||
def build_config(mtype: str, opts: Mapping) -> dict:
|
||||
"""The type-specific config from already-typed or form-string values.
|
||||
|
||||
Missing keys take their defaults; a key the type does not take, or a
|
||||
value out of range, raises ValueError saying which and why.
|
||||
"""
|
||||
unknown = sorted(set(opts) - set(CONFIG_KEYS[mtype]))
|
||||
if unknown:
|
||||
allowed = ", ".join(CONFIG_KEYS[mtype]) or "no config"
|
||||
raise ValueError(f"a {mtype} monitor does not take {', '.join(unknown)}; it takes {allowed}.")
|
||||
if mtype == MonitorType.tcp.value:
|
||||
return {"port": _whole("port", opts.get("port"), 80, 1, 65535)}
|
||||
if mtype == MonitorType.dns.value:
|
||||
return {"expected_ip": (str(opts.get("expected_ip") or "").strip() or None)}
|
||||
if mtype == MonitorType.http.value:
|
||||
method = str(opts.get("method") or "GET").strip().upper()
|
||||
if method not in HTTP_METHODS:
|
||||
raise ValueError(f"method {method!r} is not one of {', '.join(HTTP_METHODS)}.")
|
||||
return {
|
||||
"method": method,
|
||||
"expected_status": _whole("expected_status", opts.get("expected_status"), 200, 100, 599),
|
||||
"content_match": str(opts.get("content_match") or "").strip(),
|
||||
"headers": _headers(opts.get("headers")),
|
||||
"timeout_seconds": _whole("timeout_seconds", opts.get("timeout_seconds"), 10, 1, 60),
|
||||
"follow_redirects": bool(opts.get("follow_redirects", True)),
|
||||
"verify_ssl": bool(opts.get("verify_ssl", True)),
|
||||
}
|
||||
return {} # icmp
|
||||
|
||||
|
||||
def monitor_fields(mtype: str, *, target: str, name: str = "", check_interval_seconds=0,
|
||||
opts: Mapping | None = None) -> dict:
|
||||
"""Validated column values for a new or edited monitor; ValueError says why not."""
|
||||
mtype = (mtype or "").strip().lower()
|
||||
if mtype not in MONITOR_TYPE_VALUES:
|
||||
raise ValueError(f"type {mtype!r} is not one of {', '.join(MONITOR_TYPE_VALUES)}.")
|
||||
target = normalise_target(mtype, target)
|
||||
if not target:
|
||||
raise ValueError("a monitor needs a target: an address, or a URL for http.")
|
||||
if len(target) > 2048:
|
||||
raise ValueError("target is longer than 2048 characters.")
|
||||
return {
|
||||
"type": mtype,
|
||||
"target": target,
|
||||
"name": ((name or "").strip() or target)[:128],
|
||||
"config_json": json.dumps(build_config(mtype, opts or {})),
|
||||
"check_interval_seconds": _whole("check_interval_seconds", check_interval_seconds, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
async def monitor_rows(db, range_key: str):
|
||||
|
||||
Reference in New Issue
Block a user