fix(alerts): an alert that fired and ended inside maintenance isn't mailed resolved (#6097)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m39s
CI / publish (push) Successful in 1m9s

During Docker02's maintenance reboot its Agent offline alert fired muted by
the job's window, then resolved just after the window closed: the resolution
was mailed for a firing nobody had been told of. A resolution whose firing
was held and never released is now recorded with a note, not sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 07:41:51 -04:00
co-authored by Claude Opus 5.5
parent ed9da56efa
commit da81010a75
2 changed files with 72 additions and 0 deletions
+54
View File
@@ -147,6 +147,60 @@ def test_maintenance_mutes_but_the_state_moves_and_notifies_when_it_ends(app):
asyncio.run(_go())
def test_an_alert_that_fired_and_ended_unheard_is_not_mailed_resolved(app):
"""#5769: an agent offline for its own maintenance reboot fired inside the
job's window (muted) and resolved just after the window closed, before
the minute's release. Nobody heard it fire, so its end isn't mailed."""
from sqlalchemy import update
from steward.core import alerts
from steward.models.alerts import MaintenanceWindow
sent = []
async def fake_dispatch(app_, notif_type, rule, value, event_id, note=None):
sent.append((notif_type, value))
async def _go():
tag = uuid.uuid4().hex[:8]
rule_id, res, user_id = await _rule(app, tag)
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
window = MaintenanceWindow(
name=f"reboot {tag}", start_at=now - timedelta(minutes=1),
end_at=now + timedelta(hours=1), scope_source="host_agent",
scope_resource=res, created_by=user_id)
s.add(window)
window_id = window.id
async with app.db_sessionmaker() as s:
async with s.begin():
await alerts.record_metric(s, "host_agent", res, "cpu_pct", 95.0)
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(MaintenanceWindow).where(MaintenanceWindow.id == window_id)
.values(end_at=datetime.now(timezone.utc)))
async with app.db_sessionmaker() as s:
async with s.begin():
await alerts.record_metric(s, "host_agent", res, "cpu_pct", 5.0)
await alerts.notify_after_maintenance(app) # nothing left firing to release
await _settle()
assert sent == []
events = await _events(app, rule_id)
assert [(frm, to) for frm, to, _, _ in events] == [("inactive", "firing"), ("firing", "resolved")]
assert events[1][2] == alerts.HELD
# A firing that was told (released, or never muted) still gets its end.
async with app.db_sessionmaker() as s:
async with s.begin():
await alerts.record_metric(s, "host_agent", res, "cpu_pct", 96.0)
await alerts.record_metric(s, "host_agent", res, "cpu_pct", 4.0)
await _settle()
assert sent == [("firing", 96.0), ("resolved", 4.0)]
with patch.object(alerts, "_dispatch_notification", fake_dispatch):
asyncio.run(_go())
def test_an_alert_action_outside_its_source_is_refused(app, tmp_path):
from steward.core import alerts
from steward.models.ansible import AnsibleRun