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
+34 -1
View File
@@ -17,7 +17,8 @@ def _rule(metric="cpu_pct", op=AlertOperator.gt, threshold=90.0, resource="web-1
return SimpleNamespace(id=rid, name=f"{resource} {metric}", source_module="host_agent",
resource_name=resource, metric_name=metric, operator=op,
threshold=threshold, enabled=True,
consecutive_failures_required=1, consecutive_ok_required=1)
consecutive_failures_required=1, consecutive_ok_required=1,
covers="resource", cover_group=None, parent_id=None)
def _said(says):
@@ -272,3 +273,35 @@ async def test_the_rule_form_speaks_in_names_and_units(app):
assert '<option value="down" selected>' in page # the binary choice, shown
assert 'name="threshold" step="any" required disabled' in page # the raw threshold, off
assert ">is_up<" not in page and "SNMP" not in page # no raw key; empty source skipped
async def test_a_rule_covering_hosts_lists_its_hosts_under_it(app):
"""#5464: a covering rule reads as what it covers, with each host's rule
nested under it opening that host's history; its page names the hosts."""
parent = _rule("seconds_since_seen", AlertOperator.gt, 180.0, "", rid="p1")
parent.name, parent.covers, parent.cover_group = "Agent offline", "group", "swarm"
parent.ansible_action, parent.notify_no_data = None, True
kids = []
for i, host in enumerate(("node-a", "node-b")):
k = _rule("seconds_since_seen", AlertOperator.gt, 180.0, host, rid=f"k{i}")
k.name, k.parent_id = f"Agent offline: {host}", "p1"
kids.append(k)
async with app.test_request_context("/alerts/"):
session["user_role"] = "operator"
listing = await render_template(
"alerts/list.html", active=[], rules=[parent], children={"p1": kids},
rule_state={"p1": "firing", "k0": "firing", "k1": "inactive"}, operators=[])
page = await render_template(
"alerts/rules_form.html", rule=parent, operators=[], source_modules=["host_agent"],
initial_source="host_agent", resources=[], metrics=["seconds_since_seen"],
ansible_sources=[], catalog={}, history_count=0, fired=None, notify=[],
covered=["node-a", "node-b"], host_source=True, groups=["swarm"],
current_covers="group", current_group="swarm", covers_locked=True)
listing, page = " ".join(listing.split()), " ".join(page.split())
assert "every host in swarm" in listing and "2 hosts" in listing
assert listing.index("Agent offline</a>") < listing.index('href="/alerts/history?rule=k0"') \
< listing.index('href="/alerts/history?rule=k1"')
assert 'class="row-link row-child"' in listing
assert "Covers every host in <strong>swarm</strong>: 2 now (node-a, node-b)" in page
assert 'name="covers" value="group"' in page and "fixed once the rule exists" in page
assert 'data-cover-resource hidden' in page