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
+19 -2
View File
@@ -5,7 +5,8 @@ from quart import Blueprint, render_template, request, redirect, url_for, curren
from sqlalchemy import select
from steward.ansible import executor, sources as ansible_src
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core import renames
from steward.core.audit import audit_request, log_audit
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host
from steward.models.users import UserRole
@@ -107,6 +108,10 @@ async def create_host():
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
try:
await host_svc.check_name_free(db, host.name)
except ValueError as exc:
return str(exc), 400
db.add(host)
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"host.created", entity_type="host", entity_id=host.name,
@@ -139,8 +144,20 @@ async def update_host(host_id: str):
host = result.scalar_one_or_none()
if host is None:
return "Not found", 404
host.name = form["name"].strip()
old_name, name = host.name, form["name"].strip()
if name != old_name:
try:
await host_svc.check_name_free(db, name, exclude_id=host.id)
except ValueError as exc:
return str(exc), 400
host.name = name
host.address = form["address"].strip()
# Agent and container metrics, and the rules and windows on them,
# go by the host's name: move them with it.
moved = await renames.carry_rename(db, renames.HOST_SOURCES, old_name, name)
if moved:
await audit_request("host.renamed", "host", name,
{"host_id": host_id, "renamed_from": old_name, "carried": moved})
return redirect(url_for("hosts.host_detail", host_id=host_id))