feat(alerts): renames carry their alerts — monitor and host renames move rules, windows and history (#5065)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 50s
CI / unit (push) Successful in 1m6s
CI / publish (push) Successful in 1m2s

#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>
This commit is contained in:
2026-10-06 14:02:43 -04:00
co-authored by Claude Opus 5.5
parent 88d34a3827
commit c9f5c049db
9 changed files with 355 additions and 21 deletions
+14
View File
@@ -11,6 +11,8 @@ from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from steward.auth.middleware import require_role
from steward.core import renames
from steward.core.audit import audit_request
from steward.models.users import UserRole
from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES
from steward.core.time_range import DEFAULT_RANGE
@@ -81,6 +83,10 @@ async def add_monitor():
created_at=datetime.now(timezone.utc), **fields)
async with current_app.db_sessionmaker() as db:
async with db.begin():
try:
await monitor_svc.check_name_free(db, monitor.type, monitor.name)
except ValueError as exc:
return str(exc), 400
db.add(monitor)
# A host-scoped add returns to that host's hub; otherwise the monitors page.
@@ -117,10 +123,18 @@ async def edit_monitor(monitor_id: str):
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))
if fields["name"] != m.name:
await monitor_svc.check_name_free(db, m.type, fields["name"], exclude_id=m.id)
except ValueError as exc:
return str(exc), 400
old_name = m.name
for k, v in fields.items():
setattr(m, k, v)
# Its rules, windows and history go by (type, name): move them with it.
moved = await renames.carry_rename(db, ((m.type, None),), old_name, m.name)
if moved:
await audit_request("monitor.updated", "monitor", m.name,
{"monitor_id": m.id, "renamed_from": old_name, "carried": moved})
return redirect(url_for("monitors.index"))
+14
View File
@@ -114,6 +114,20 @@ def monitor_fields(mtype: str, *, target: str, name: str = "", check_interval_se
}
async def check_name_free(db, mtype: str, name: str, exclude_id: str | None = None) -> None:
"""ValueError when another monitor of this type already has this name.
A monitor's checks are recorded, and its alert rules watch it, by
(type, name), so two monitors sharing both would share one history.
"""
q = select(Monitor.id).where(Monitor.type == mtype, Monitor.name == name)
if exclude_id:
q = q.where(Monitor.id != exclude_id)
if (await db.execute(q.limit(1))).first():
raise ValueError(f"another {mtype} monitor is already named {name!r}; its checks and "
"alert rules go by that name, so pick a different one.")
async def monitor_rows(db, range_key: str):
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days.