feat(alerts): renames carry their alerts — monitor and host renames move rules, windows and history (#5065)
#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:
+19
-2
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import and_, case, func, select
|
||||
|
||||
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.monitors import Monitor, MonitorResult
|
||||
|
||||
|
||||
@@ -18,6 +19,21 @@ from steward.models.monitors import Monitor, MonitorResult
|
||||
AGENT_GLANCE_METRICS = ("cpu_pct", "mem_used_pct", "disk_used_pct", "disk_used_pct_worst")
|
||||
|
||||
|
||||
async def check_name_free(db, name: str, exclude_id: str | None = None) -> None:
|
||||
"""ValueError when another host already has this name, or it is blank.
|
||||
|
||||
Agent metrics, container metrics and the alert rules on them go by the
|
||||
host's name, so two hosts sharing one would share one history.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("a host needs a name.")
|
||||
q = select(Host.id).where(Host.name == name)
|
||||
if exclude_id:
|
||||
q = q.where(Host.id != exclude_id)
|
||||
if (await db.execute(q.limit(1))).first():
|
||||
raise ValueError(f"another host is already named {name!r}; pick a different name.")
|
||||
|
||||
|
||||
def agent_stale_after_seconds(config) -> int:
|
||||
"""The host_agent plugin's freshness window from app config (default 180s)."""
|
||||
return int(((config.get("PLUGINS") or {}).get("host_agent") or {})
|
||||
|
||||
Reference in New Issue
Block a user