feat(alerts): one rule can cover every host, or every host in a group (#5464)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m7s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m12s

#5464 "Group rules: one rule covers every host, or every host in a group",
step 2 of milestone 499 "Alerts you can trust". Each rule named one host, so
the live instance carried six rules for three hosts, and a new host had no
agent-offline or disk alert until someone copied them.

- alert_rules.covers / cover_group / parent_id (migration 0041). A rule on
  host_agent may cover "all_hosts" or a "group" (an inventory group: the
  Hosts ruling, #5294). A covering rule has no state; Steward keeps one
  ordinary child rule per covered host, named "<rule>: <host>", so each
  host's alert keeps its own state, history and acknowledgement.
- alerts.service.reconcile_children: a covered host that has reported in
  the last 7 days gets a child; a host no longer covered loses its child;
  children take the parent's condition, checks, notify setting, action and
  on/off. Run on save and each minute (alert_reconcile).
- A child is changed through its parent: edit, toggle and delete of a child
  are refused (409 / PartOfCoveringRule); its edit page opens the parent.
- Rule form: a Covers choice for host sources, with the group picker; fixed
  once the rule exists. Rules list: a covering rule reads "every host in
  swarm · 3 hosts" with its hosts' rules nested under it, each opening its
  history. The history picker and the rule count leave covering rules out.
- MCP: create_alert_rule takes covers/group; update_alert_rule takes group
  and passes changes down; list_alert_rules shows covers, hosts and part_of.
- docs/core/alerting.md: "Rules that cover hosts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 08:25:51 -04:00
co-authored by Claude Opus 5.5
parent 734d313a54
commit 48a0de5600
13 changed files with 647 additions and 39 deletions
+70 -8
View File
@@ -36,6 +36,8 @@ def _rule_form(form) -> dict:
"consecutive_failures_required": form.get("consecutive_failures_required"),
"consecutive_ok_required": form.get("consecutive_ok_required"),
"notify_no_data": "notify_no_data" in form,
"covers": form.get("covers") or "resource",
"cover_group": form.get("cover_group", ""),
}
@@ -47,6 +49,15 @@ def _form_time(value) -> datetime:
raise ValueError(f"{value!r} is not a date and time.") from None
async def _child_conflict(db, rule: AlertRule):
"""A 409 for a host's rule under a covering rule, which is changed
through its parent (#5464); None for any other rule."""
if not rule.parent_id:
return None
parent = await db.get(AlertRule, rule.parent_id)
return str(alert_svc.PartOfCoveringRule(rule, parent.name if parent else "?")), 409
def _is_admin() -> bool:
return session.get("user_role") == UserRole.admin.value
@@ -123,11 +134,27 @@ async def list_alerts():
states = {s.rule_id: s for s in (await db.execute(select(AlertState))).scalars()}
fired = await alert_svc.last_fired(db)
# Every rule with what it is doing now; disabled rules last.
rules.sort(key=lambda r: (not r.enabled, r.name.lower()))
# Every rule with what it is doing now; disabled rules last. A covering
# rule's per-host children sit under it (#5464), and it reads as the most
# pressing of their states.
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
children: dict[str, list] = {}
for r in rules:
if r.parent_id:
children.setdefault(r.parent_id, []).append(r)
for kids in children.values():
kids.sort(key=lambda r: r.resource_name.lower())
parent_id = kids[0].parent_id
labels = [rule_state.get(k.id) for k in kids]
rule_state[parent_id] = min(
(lb for lb in labels if lb and lb not in ("inactive", "resolved")),
key=lambda lb: alert_svc.ACTIVE_ORDER.get(lb, 9), default="inactive")
fired[parent_id] = max((fired[k.id] for k in kids if k.id in fired), default=None)
rules = sorted((r for r in rules if not r.parent_id),
key=lambda r: (not r.enabled, r.name.lower()))
return await render_template(
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
"alerts/list.html", active=active, rules=rules, children=children,
rule_state=rule_state, last_fired={k: v for k, v in fired.items() if v},
notify=await _notify(), operators=_OPERATORS,
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
@@ -138,7 +165,8 @@ async def active_fragment():
"""The verdict and Active section alone, which the alerts page polls."""
async with current_app.db_sessionmaker() as db:
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
rules = await alert_svc.list_rules(db)
# Rules as people made them: a covering rule counts once, not per host.
rules = [r for r in await alert_svc.list_rules(db) if not r.parent_id]
return await render_template(
"alerts/_active.html", active=active, notify=await _notify(), rules_total=len(rules),
rules_off=sum(1 for r in rules if not r.enabled))
@@ -195,7 +223,8 @@ async def alert_history():
since, range_key = parse_range(request.args.get("range") or "7d")
before = request.args.get("before")
async with current_app.db_sessionmaker() as db:
rules = await alert_svc.list_rules(db)
# A covering rule has no history of its own; its hosts' rules do.
rules = [r for r in await alert_svc.list_rules(db) if r.covers == "resource"]
chosen = next((r for r in rules if r.id == request.args.get("rule")), None)
rule_id = chosen.id if chosen else None
rows = await alert_svc.history(db, since, rule_id, PAGE_SIZE, before)
@@ -232,26 +261,40 @@ async def rule_fields_api():
current_metric = request.args.get("current_metric", "")
async with current_app.db_sessionmaker() as db:
resources = await alert_svc.resources_for(db, source) if source else []
async with current_app.db_sessionmaker() as db:
metrics = await alert_svc.metrics_for(db, source)
cover = await _cover_choices(db, source)
return await render_template(
"alerts/_rule_fields.html",
resources=resources,
metrics=metrics,
current_resource=current_resource,
current_metric=current_metric,
**cover,
)
async def _cover_choices(db, source: str, rule: AlertRule | None = None) -> dict:
"""What the rule form offers for covering hosts (#5464): whether this
source's resources are hosts, the inventory groups, and the rule's own
choice, fixed once it exists."""
from steward.hosts.service import group_names
host_source = source in alert_svc.HOST_SOURCES
return {"host_source": host_source, "groups": await group_names(db) if host_source else [],
"current_covers": rule.covers if rule else "resource",
"current_group": (rule.cover_group or "") if rule else "",
"covers_locked": rule is not None}
@alerts_bp.get("/rules/new")
@require_role(UserRole.operator)
async def new_rule():
first_source = alert_svc.source_modules()[0]
async with current_app.db_sessionmaker() as db:
resources = await alert_svc.resources_for(db, first_source)
cover = await _cover_choices(db, first_source)
return await render_template(
"alerts/rules_form.html",
rule=None,
rule=None, **cover,
operators=_OPERATORS,
source_modules=alert_svc.source_modules(),
initial_source=first_source,
@@ -271,13 +314,20 @@ async def edit_rule(rule_id: str):
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
if rule.parent_id:
# A host's rule under a covering rule is changed through it.
return redirect(f"/alerts/rules/{rule.parent_id}/edit")
resources = await alert_svc.resources_for(db, rule.source_module)
cover = await _cover_choices(db, rule.source_module, rule)
hosts = sorted((c.resource_name for c in (await db.execute(
select(AlertRule).where(AlertRule.parent_id == rule.id))).scalars()), key=str.lower)
history_count = (await db.execute(
select(func.count()).select_from(AlertEvent).where(AlertEvent.rule_id == rule.id))).scalar_one()
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
return await render_template(
"alerts/rules_form.html",
rule=rule, history_count=history_count, fired=fired, notify=await _notify(),
covered=hosts, **cover,
operators=_OPERATORS,
source_modules=alert_svc.source_modules(),
initial_source=rule.source_module,
@@ -331,13 +381,19 @@ async def update_rule(rule_id: str):
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
if (conflict := await _child_conflict(db, rule)):
return conflict
try:
fields = await alert_svc.rule_fields(db, **_rule_form(form))
# What a rule covers is fixed once it exists; its group may change.
fields = await alert_svc.rule_fields(db, **{**_rule_form(form),
"covers": rule.covers})
except ValueError as exc:
return str(exc), 400
await alert_svc.apply_rule_change(db, rule, fields, actor_id=session.get("user_id"))
if apply_action:
rule.ansible_action = ansible_action
if rule.covers != "resource":
await alert_svc.reconcile_children(db, rule, actor_id=session.get("user_id"))
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"alert_rule.updated", entity_type="alert_rule", entity_id=rule.name)
return redirect(url_for("alerts.list_alerts"))
@@ -352,8 +408,12 @@ async def toggle_rule(rule_id: str):
rule = result.scalar_one_or_none()
if rule is None:
return "Not found", 404
if (conflict := await _child_conflict(db, rule)):
return conflict
await alert_svc.apply_rule_change(db, rule, {}, enabled=not rule.enabled,
actor_id=session.get("user_id"))
if rule.covers != "resource":
await alert_svc.reconcile_children(db, rule, actor_id=session.get("user_id"))
action = "alert_rule.enabled" if rule.enabled else "alert_rule.disabled"
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
action, entity_type="alert_rule", entity_id=rule.name)
@@ -369,6 +429,8 @@ async def delete_rule(rule_id: str):
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule:
if (conflict := await _child_conflict(db, rule)):
return conflict
rule_name = rule.name
await db.delete(rule)
if rule_name: