feat: Phase 6b rules with delay, delta format, reminders, zpool default rule
test / pytest (3.11) (push) Waiting to run
test / pytest (3.13) (push) Waiting to run

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-06 02:38:10 +01:00
1 parent 9c4f7366eb
commit 5278530467
58 files changed
+1781 -230

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import json
import httpx
from app.chat import MatrixAdapter, MessageRef, TelegramAdapter
ROOM = "!abc:example.de"
def telegram(seen):
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True, "result": {"message_id": 7}})
return TelegramAdapter("123456:token", "42", transport=httpx.MockTransport(handler))
def matrix(seen):
def handler(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"event_id": "$ev1"})
return MatrixAdapter("https://matrix.example.de", "@bot:example.de", "tok", ROOM,
transport=httpx.MockTransport(handler))
async def test_telegram_silent_sets_disable_notification():
seen = []
await telegram(seen).send("42", "x", silent=True)
assert seen[0]["disable_notification"] is True
async def test_telegram_loud_send_has_no_flag():
seen = []
await telegram(seen).send("42", "x")
assert "disable_notification" not in seen[0]
async def test_telegram_edit_accepts_silent():
seen = []
await telegram(seen).edit(MessageRef("telegram", "42", "7"), "neu", silent=True)
assert seen[0]["message_id"] == 7
async def test_matrix_silent_uses_notice():
seen = []
await matrix(seen).send(ROOM, "<b>x</b>", silent=True)
assert seen[0]["msgtype"] == "m.notice"
async def test_matrix_loud_uses_text():
seen = []
await matrix(seen).send(ROOM, "<b>x</b>")
assert seen[0]["msgtype"] == "m.text"
async def test_matrix_silent_edit_keeps_notice_type():
seen = []
await matrix(seen).edit(MessageRef("matrix", ROOM, "$ev0"), "neu", silent=True)
assert seen[0]["msgtype"] == "m.notice" and seen[0]["m.new_content"]["msgtype"] == "m.notice"
+6 -5
View File
@@ -66,8 +66,9 @@ async def test_downtimes_all_and_by_host():
client = make_client(handler)
result = await client.downtimes()
assert seen[0].method == "GET" and seen[0].url.path.endswith("/domain-types/downtime/collections/all")
assert "host_name" not in seen[0].url.params
assert result[0].service is None and result[0].comment == "Wartung"
await client.downtimes("web01")
assert seen[1].url.params["host_name"] == "web01"
assert all(r.method == "GET" and r.url.path.endswith("/domain-types/downtime/collections/all") for r in seen)
assert [r.url.params["downtime_type"] for r in seen] == ["host", "service"]
assert all("host_name" not in r.url.params for r in seen)
assert len(result) == 2 and result[0].service is None and result[0].comment == "Wartung"
assert await client.downtimes("WEB01") == result
assert await client.downtimes("db01") == []
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from app.alerts import format_message
from app.alerts.delta import format_duration
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, Event
NOW = int(datetime(2026, 11, 6, 20, 0).timestamp())
SINCE = int(datetime(2026, 11, 6, 18, 5).timestamp())
HOUR = 3600
def make(kind=KIND_PROBLEM, state=2, **kwargs):
return Event(ts=NOW, host="h1", service="zpool", kind=kind, state=state, output="kaputt", **kwargs)
def test_format_duration_is_compact():
assert format_duration(10) == "unter 1 min"
assert format_duration(60) == "1 min"
assert format_duration(59 * 60) == "59 min"
assert format_duration(2 * HOUR) == "2 h"
assert format_duration(3 * HOUR + 12 * 60) == "3 h 12 min"
assert format_duration(24 * HOUR) == "1 d"
assert format_duration(26 * HOUR + 600) == "1 d 2 h"
assert format_duration(-5) == "unter 1 min"
def test_transition_line_shows_arrow_since_and_previous_duration():
event = make(previous_state=1, previous_duration=2 * HOUR, since_ts=SINCE)
text = format_message([event])
assert "<b>WARN → CRIT</b> zpool, seit 18:05 (vorher WARN seit 2 h): kaputt" in text
def test_new_problem_line_has_no_arrow():
text = format_message([make(since_ts=SINCE)])
assert "<b>CRIT</b> zpool, seit 18:05: kaputt" in text and "→" not in text
def test_old_since_includes_date():
event = make(since_ts=int(datetime(2026, 11, 4, 9, 30).timestamp()))
assert "seit 04.11. 09:30" in format_message([event])
def test_problem_without_delta_keeps_plain_format():
assert "<b>CRIT</b> zpool: kaputt" in format_message([make()])
def test_recovery_shows_duration_and_last_state():
event = make(kind=KIND_RECOVERY, state=0, previous_state=2, problem_since_ts=NOW - (3 * HOUR + 12 * 60))
assert "<b>OK</b> zpool: wieder OK nach 3 h 12 min (zuletzt CRIT)" in format_message([event])
def test_recovery_without_delta_is_plain():
assert "<b>OK</b> zpool: wieder OK" in format_message([make(kind=KIND_RECOVERY, state=0)])
def test_reminder_line():
event = make(kind=KIND_REMINDER, since_ts=NOW - 4 * HOUR)
assert "<b>CRIT</b> zpool: weiter CRIT seit 4 h: kaputt" in format_message([event])
def test_flapping_line_unchanged():
assert "<b>FLAPPING (CRIT)</b> zpool: kaputt" in format_message([make(kind=KIND_FLAPPING)])
def test_foreign_text_stays_escaped_with_delta():
event = Event(ts=NOW, host="<h>", service="<s>&", kind=KIND_PROBLEM, state=2, output="<script>x</script>",
previous_state=1, previous_duration=60, since_ts=SINCE)
text = format_message([event])
assert "<script>" not in text and "&lt;script&gt;" in text
assert "&lt;s&gt;&amp;" in text and "<h>" not in text
+3 -2
View File
@@ -64,7 +64,7 @@ def problem(host="h1", service="disk", state=2, output="Platte voll", **kwargs):
class Env:
def __init__(self, ollama=None, ai_per_minute=None, adapter_list=None, routes=None, mutes=None,
queue_size=1000):
queue_size=1000, recovery_enabled=True):
self.db = Database(":memory:")
self.store = SettingsStore(self.db, SecretBox(os.urandom(32)))
if ai_per_minute is not None:
@@ -81,7 +81,8 @@ class Env:
analyzer = AlertAnalyzer(self.store, lambda: self.ollama, lambda host, service, limit: [],
SlidingWindowLimiter(self.clock))
self.pipeline = AlertPipeline(self.repo, adapters, analyzer, lambda: BUNDLE, self.clock,
routes=routes, mutes=mutes, queue_size=queue_size)
routes=routes, mutes=mutes, queue_size=queue_size,
recovery_enabled=lambda: recovery_enabled)
async def handle(self, events):
await self.pipeline.handle(events)
+191
View File
@@ -0,0 +1,191 @@
from app.alerts import ReportGate, build_system_prompt
from app.alerts.prompts import DATA_NOTICE, DATA_OPEN
from app.chat import Target
from app.models import (KIND_RECOVERY, KIND_REMINDER, SUPPRESS_DOWNTIME, SUPPRESS_NOT_REPORTED,
SUPPRESS_RECOVERY_OFF, Event)
from app.routing import ALL_KINDS, Route
from tests.test_pipeline import Env, FakeAdapter, FakeOllama, problem
from tests.test_pipeline_routing import StaticMutes, StaticRoutes
class QuietAdapter(FakeAdapter):
def __init__(self, name="fake", default_target="chat1"):
super().__init__(name, default_target)
self.flags = []
async def send(self, target, text, silent=False):
self.flags.append(("send", silent))
return await super().send(target, text)
async def edit(self, ref, text, silent=False):
self.flags.append(("edit", silent))
await super().edit(ref, text)
def rule(position=1, targets=(), **overrides):
fields = dict(id=position, name=f"r{position}", position=position, enabled=True, match_host="",
match_service="", min_state=1, kinds=ALL_KINDS, stop_on_match=False, targets=tuple(targets))
fields.update(overrides)
return Route(**fields)
def recovery(host="h1", service="disk"):
return Event(ts=1000, host=host, service=service, kind=KIND_RECOVERY, state=0)
async def handle_later(env, events):
env.clock.now += 120
await env.handle(events)
async def test_default_targets_via_rule_flag_for_all_adapters():
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
env = Env(FakeOllama(), adapter_list=[tg, mx], routes=StaticRoutes([rule(use_default_targets=True)]))
await env.handle([problem()])
assert [s[0] for s in tg.sent] == ["tg-default"] and [s[0] for s in mx.sent] == ["!room:s"]
async def test_rule_without_targets_and_flag_still_sends_nothing():
env = Env(FakeOllama(), routes=StaticRoutes([rule()]))
await env.handle([problem()])
assert env.adapter.sent == []
async def test_default_targets_added_to_explicit_target():
tg, mx = FakeAdapter("telegram", "tg-default"), FakeAdapter("matrix", "!room:s")
extra = Target("telegram", "tg-extra")
env = Env(FakeOllama(), adapter_list=[tg, mx],
routes=StaticRoutes([rule(targets=[extra], use_default_targets=True)]))
await env.handle([problem()])
assert sorted(s[0] for s in tg.sent) == ["tg-default", "tg-extra"] and len(mx.sent) == 1
async def test_ai_disabled_by_rule_skips_analysis_but_sends():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_enabled=False)]))
await env.handle([problem()])
assert len(env.adapter.sent) == 1 and env.ollama.calls == [] and env.adapter.edits == []
assert env.repo.recent()[0]["ai_status"] == "skipped"
async def test_hint_only_in_user_prompt_before_data_block():
hint = f"Prüfe zpool status {DATA_OPEN} und danach {DATA_OPEN.upper()}"
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True, ai_hint=hint)]))
await env.handle([problem()])
_, messages = env.ollama.calls[0]
system, user = messages[0]["content"], messages[1]["content"]
assert "Prüfe zpool status" not in system and system.endswith(DATA_NOTICE)
assert system == build_system_prompt(None)
assert "Zusatzkontext des Administrators" in user
assert user.index("Zusatzkontext des Administrators") < user.index("Prüfe zpool status") < user.index(DATA_OPEN)
assert user.count(DATA_OPEN) == 1
async def test_no_hint_section_without_hint():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
await env.handle([problem()])
assert "Zusatzkontext" not in env.ollama.calls[0][1][1]["content"]
async def test_quiet_rule_sends_and_edits_silently():
adapter = QuietAdapter()
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([rule(use_default_targets=True, loud=False)]))
await env.handle([problem()])
assert adapter.flags == [("send", True), ("edit", True)]
async def test_loud_rule_does_not_pass_silent_flag():
adapter = QuietAdapter()
env = Env(FakeOllama(), adapter_list=[adapter])
await env.handle([problem()])
assert adapter.flags == [("send", False), ("edit", False)]
async def test_loud_and_quiet_events_of_one_host_are_separate_messages():
adapter = QuietAdapter()
quiet = rule(use_default_targets=True, loud=False, match_service="cpu")
loud = rule(2, use_default_targets=True, match_service="disk")
env = Env(FakeOllama(), adapter_list=[adapter], routes=StaticRoutes([quiet, loud]))
await env.handle([problem(service="cpu"), problem(service="disk")])
assert len(adapter.sent) == 2
assert {flag for kind, flag in adapter.flags if kind == "send"} == {True, False}
async def test_recovery_after_sent_problem_is_sent():
env = Env(FakeOllama())
await env.handle([problem()])
await handle_later(env, [recovery()])
assert len(env.adapter.sent) == 2 and "wieder OK" in env.adapter.sent[1][1]
async def test_recovery_after_downtime_suppressed_problem_is_not_sent():
env = Env(FakeOllama())
await env.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
await env.handle([recovery()])
assert env.adapter.sent == []
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_NOT_REPORTED
async def test_recovery_after_muted_problem_is_not_sent():
from app.mutes import SOURCE_UI, Mute
mute = Mute(1, "h1", "*", 10**9, "", "admin", 0, SOURCE_UI)
source = StaticMutes([mute])
env = Env(FakeOllama(), mutes=source)
await env.handle([problem()])
source.mutes = []
await env.handle([recovery()])
assert env.adapter.sent == []
assert [a for a in env.repo.recent() if a["kind"] == "recovery"][0]["suppress_reason"] == SUPPRESS_NOT_REPORTED
async def test_recovery_sent_when_earlier_state_was_reported_even_if_worsening_was_suppressed():
env = Env(FakeOllama())
await env.handle([problem(state=1)])
await handle_later(env, [problem(state=2, suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
await handle_later(env, [recovery()])
assert len(env.adapter.sent) == 2
async def test_report_state_is_restored_from_history_after_restart():
suppressed = Env(FakeOllama())
await suppressed.handle([problem(suppressed=True, suppress_reason=SUPPRESS_DOWNTIME)])
suppressed.pipeline._gate = ReportGate(suppressed.repo.report_counts)
await suppressed.handle([recovery()])
assert suppressed.adapter.sent == []
sent = Env(FakeOllama())
await sent.handle([problem()])
sent.pipeline._gate = ReportGate(sent.repo.report_counts)
await handle_later(sent, [recovery()])
assert len(sent.adapter.sent) == 2
async def test_recovery_without_any_history_is_sent():
env = Env(FakeOllama())
await env.handle([recovery()])
assert len(env.adapter.sent) == 1
async def test_recovery_switch_off_stores_but_does_not_send():
env = Env(FakeOllama(), recovery_enabled=False)
await env.handle([problem()])
await env.handle([recovery()])
assert len(env.adapter.sent) == 1
stored = [a for a in env.repo.recent() if a["kind"] == "recovery"][0]
assert stored["suppressed"] == 1 and stored["suppress_reason"] == SUPPRESS_RECOVERY_OFF
async def test_reminder_goes_through_routing_and_is_stored_without_ai():
env = Env(FakeOllama(), routes=StaticRoutes([rule(use_default_targets=True)]))
reminder = Event(ts=1000, host="h1", service="disk", kind=KIND_REMINDER, state=2, output="voll",
since_ts=1000 - 4 * 3600)
await env.handle([reminder])
assert "weiter CRIT seit 4 h: voll" in env.adapter.sent[0][1]
assert env.ollama.calls == []
assert env.repo.recent()[0]["kind"] == "reminder" and env.repo.recent()[0]["ai_status"] == "skipped"
async def test_reminder_without_matching_rule_goes_to_default_targets():
env = Env(FakeOllama(), routes=StaticRoutes([rule(match_host="db*", use_default_targets=True)]))
await env.handle([Event(ts=1000, host="web01", service="disk", kind=KIND_REMINDER, state=2)])
assert len(env.adapter.sent) == 1
+3 -3
View File
@@ -4,16 +4,16 @@ from app.models import (KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, SUPPRESS_DOW
from app.poller import Poller, PollerStatus, StateStore, compute_events
from app.poller.events import FLAP_QUIET_SECONDS
NOW = 10_000
NOW = 100_000
def svc(state=2, host="h1", service="cpu", hard=True, output="boom", changed=NOW - 100,
def svc(state=2, host="h1", service="cpu", hard=True, output="boom", changed=NOW - 3600,
downtime=False, flapping=False):
return ServiceState(host, service, state, hard, output, changed, downtime, flapping)
def run(previous, current, now=NOW, min_duration=0):
events, rows = compute_events(previous, current, now, min_duration)
events, rows = compute_events(previous, current, now, lambda *_: min_duration)
return events, {row.key: row for row in rows}
+118
View File
@@ -0,0 +1,118 @@
from app.checkmk import ServiceState
from app.models import KIND_PROBLEM, KIND_RECOVERY, STATE_OK, SUPPRESS_DOWNTIME
from app.poller import compute_events
from app.routing import ALL_KINDS, Route, delay_provider
NOW = 100_000
DEFAULT_MINUTES = 15
def svc(state=2, service="cpu", changed=NOW, downtime=False, output="boom", flapping=False):
return ServiceState("h1", service, state, True, output, changed, downtime, flapping)
def zpool_rule():
return Route(1, "ZFS", 1, True, "*", "*zpool*", 1, ALL_KINDS, False, (), delay_minutes=0)
def make_run(rules=()):
provider = delay_provider(list(rules), DEFAULT_MINUTES)
def run(previous, current, now):
events, rows = compute_events(previous, current, now, provider)
return events, {row.key: row for row in rows}
return run
def test_problem_not_reported_before_delay_expires():
run = make_run()
events, rows = run({}, [svc(changed=NOW)], NOW + 899)
assert events == [] and rows[("h1", "cpu")].reported_state == STATE_OK
def test_problem_reported_after_delay_expires():
run = make_run()
_, rows = run({}, [svc(changed=NOW)], NOW + 30)
events, rows = run(rows, [svc(changed=NOW)], NOW + 900)
assert [(e.kind, e.state) for e in events] == [(KIND_PROBLEM, 2)]
assert rows[("h1", "cpu")].reported_state == 2 and rows[("h1", "cpu")].reported_at == NOW + 900
def test_zpool_immediately_other_service_after_15_minutes():
run = make_run([zpool_rule()])
current = [svc(state=1, service="zpool status", changed=NOW), svc(state=1, service="cpu", changed=NOW)]
events, rows = run({}, current, NOW + 30)
assert [e.service for e in events] == ["zpool status"]
events, _ = run(rows, current, NOW + 900)
assert [e.service for e in events] == ["cpu"]
def test_worsening_restarts_the_delay():
run = make_run()
_, rows = run({}, [svc(state=1, changed=NOW)], NOW + 900)
worse = [svc(state=2, changed=NOW + 900)]
events, rows = run(rows, worse, NOW + 1000)
assert [e.state for e in events] == []
events, _ = run(rows, worse, NOW + 900 + 900)
assert [e.state for e in events] == [2]
def test_problem_gone_before_delay_gives_neither_alert_nor_recovery():
run = make_run()
_, rows = run({}, [svc(changed=NOW)], NOW + 60)
events, rows = run(rows, [], NOW + 120)
assert events == [] and rows == {}
def test_downtime_during_wait_suppresses_event_at_expiry():
run = make_run()
_, rows = run({}, [svc(changed=NOW)], NOW + 60)
events, _ = run(rows, [svc(changed=NOW, downtime=True)], NOW + 900)
assert len(events) == 1 and events[0].suppressed and events[0].suppress_reason == SUPPRESS_DOWNTIME
def test_downtime_ended_before_expiry_reports_normally_without_catch_up():
run = make_run()
_, rows = run({}, [svc(changed=NOW, downtime=True)], NOW + 60)
events, rows = run(rows, [svc(changed=NOW)], NOW + 600)
assert events == []
events, _ = run(rows, [svc(changed=NOW)], NOW + 900)
assert len(events) == 1 and not events[0].suppressed
def test_problem_since_survives_warn_crit_changes():
run = make_run([zpool_rule()])
current = [svc(state=1, service="zpool", changed=NOW - 7200)]
_, rows = run({}, current, NOW)
assert rows[("h1", "zpool")].problem_since == NOW - 7200
events, rows = run(rows, [svc(state=2, service="zpool", changed=NOW + 30)], NOW + 60)
assert rows[("h1", "zpool")].problem_since == NOW - 7200
assert events[0].problem_since_ts == NOW - 7200 and events[0].since_ts == NOW + 30
def test_transition_event_carries_previous_state_and_duration():
run = make_run([zpool_rule()])
_, rows = run({}, [svc(state=1, service="zpool", changed=NOW - 7200)], NOW)
events, _ = run(rows, [svc(state=2, service="zpool", changed=NOW)], NOW + 30)
assert events[0].previous_state == 1 and events[0].previous_duration == 7200
def test_previous_duration_known_although_delay_phase_updated_the_row():
run = make_run()
_, rows = run({}, [svc(state=1, changed=NOW - 7200)], NOW)
_, rows = run(rows, [svc(state=2, changed=NOW)], NOW + 30)
events, _ = run(rows, [svc(state=2, changed=NOW)], NOW + 900)
assert events[0].previous_state == 1 and events[0].previous_duration == 7200
def test_new_problem_has_no_previous_state():
events, _ = make_run([zpool_rule()])({}, [svc(service="zpool", changed=NOW)], NOW)
assert events[0].previous_state is None and events[0].problem_since_ts == NOW
def test_recovery_event_carries_last_state_and_problem_start():
run = make_run([zpool_rule()])
_, rows = run({}, [svc(state=2, service="zpool", changed=NOW - 3600)], NOW)
events, _ = run(rows, [], NOW + 600)
assert [e.kind for e in events] == [KIND_RECOVERY]
assert events[0].previous_state == 2 and events[0].problem_since_ts == NOW - 3600
+164
View File
@@ -0,0 +1,164 @@
from app.db import Database
from app.models import KIND_REMINDER, StateRow
from app.mutes import SOURCE_UI, Mute
from app.poller import StateStore
from app.reminders import ReminderWorker, due_reminders
from app.routing import ALL_KINDS, Route
NOW = 1_000_000
HOUR = 3600
def row(state=2, reported=2, reported_at=NOW - 5 * HOUR, last_reminder=0, downtime=False, flapping=False,
service="zpool status"):
return StateRow("h1", service, state, "kaputt", NOW - 6 * HOUR, downtime, flapping, reported, False,
NOW - 7 * HOUR, reported_at, NOW - 6 * HOUR, last_reminder)
def rule(hours=4, host="*", position=1):
return Route(position, f"r{position}", position, True, host, "", 1, ALL_KINDS, False, (), reminder_hours=hours)
def due(rows, rules=(), mutes=(), default=0, now=NOW):
return due_reminders(rows, list(rules), list(mutes), default, now)
def test_reminder_when_interval_elapsed_since_report():
(event,) = due([row()], [rule(4)])
assert event.kind == KIND_REMINDER and event.state == 2 and event.output == "kaputt"
assert event.since_ts == NOW - 6 * HOUR and event.problem_since_ts == NOW - 7 * HOUR
def test_no_reminder_before_interval():
assert due([row(reported_at=NOW - 3 * HOUR)], [rule(4)]) == []
def test_last_reminder_counts_when_newer_than_report():
assert due([row(last_reminder=NOW - 3 * HOUR)], [rule(4)]) == []
assert len(due([row(last_reminder=NOW - 4 * HOUR)], [rule(4)])) == 1
def test_only_crit_state_and_reported_crit():
assert due([row(state=1, reported=1)], [rule(4)]) == []
assert due([row(state=1, reported=2)], [rule(4)]) == []
assert due([row(state=2, reported=1)], [rule(4)]) == []
assert due([row(state=2, reported=0)], [rule(4)]) == []
def test_no_reminder_during_downtime_or_flapping():
assert due([row(downtime=True)], [rule(4)]) == []
assert due([row(flapping=True)], [rule(4)]) == []
def test_no_reminder_during_mute():
mute = Mute(1, "h1", "*zpool*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
assert due([row()], [rule(4)], [mute]) == []
expired = Mute(1, "h1", "*zpool*", NOW - 1, "", "admin", 0, SOURCE_UI)
assert len(due([row()], [rule(4)], [expired])) == 1
def test_smallest_rule_interval_wins_and_overrides_default():
rules = [rule(8, position=1), rule(2, position=2)]
assert len(due([row(reported_at=NOW - 2 * HOUR)], rules, default=6)) == 1
assert due([row(reported_at=NOW - 5 * HOUR)], [rule(8)], default=2) == []
def test_global_default_applies_without_rule_interval():
assert due([row()], [], default=0) == []
assert len(due([row()], [], default=4)) == 1
assert len(due([row()], [rule(0)], default=4)) == 1
def test_rule_for_other_host_does_not_apply():
assert due([row()], [rule(4, host="db*")], default=0) == []
class Env:
def __init__(self, rules=(), mutes=(), default=0):
self.db = Database(":memory:")
self.state = StateStore(self.db)
self.sent = []
self.clock = [NOW]
self.rules, self.mutes, self.default = list(rules), list(mutes), default
self.worker = self.make_worker()
def make_worker(self):
async def sink(events):
self.sent.extend(events)
return ReminderWorker(self.state.load, lambda: self.rules, lambda now: self.mutes, lambda: self.default,
sink, self.state.mark_reminded, clock=lambda: self.clock[0])
def store(self, state_row):
self.state.apply({}, [state_row], NOW)
async def test_worker_sends_and_persists_timestamp():
env = Env([rule(4)])
env.store(row())
assert await env.worker.tick() == 1
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
assert len(env.sent) == 1 and env.sent[0].kind == KIND_REMINDER
async def test_worker_waits_full_interval_for_next_reminder():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
env.clock[0] = NOW + 3 * HOUR
assert await env.worker.tick() == 0
env.clock[0] = NOW + 4 * HOUR
assert await env.worker.tick() == 1
async def test_no_double_send_after_restart():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
env.sent.clear()
restarted = env.make_worker()
env.clock[0] = NOW + 60
assert await restarted.tick() == 0 and env.sent == []
async def test_worker_skips_muted_and_downtime_services():
mute = Mute(1, "h1", "*", NOW + HOUR, "", "admin", 0, SOURCE_UI)
env = Env([rule(4)], [mute])
env.store(row())
assert await env.worker.tick() == 0
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == 0
async def test_poller_upsert_keeps_reminder_timestamp():
env = Env([rule(4)])
env.store(row())
await env.worker.tick()
previous = env.state.load()
changed = previous[("h1", "zpool status")]
from dataclasses import replace
stale = replace(changed, output="neu", last_reminder_ts=0)
env.state.apply(previous, [stale], NOW + 30)
assert env.state.load()[("h1", "zpool status")].last_reminder_ts == NOW
async def test_worker_error_in_sink_does_not_stop_run_loop():
import asyncio
env = Env([rule(4)])
env.store(row())
calls = []
async def failing_sink(events):
calls.append(1)
raise RuntimeError("pipeline kaputt")
async def sleep(seconds):
if len(calls) >= 1:
raise asyncio.CancelledError
worker = ReminderWorker(env.state.load, lambda: env.rules, lambda now: [], lambda: 0, failing_sink,
env.state.mark_reminded, clock=lambda: NOW, sleep=sleep)
try:
await worker.run()
except asyncio.CancelledError:
pass
assert calls == [1]
+124
View File
@@ -0,0 +1,124 @@
from app.chat import Target
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY, KIND_REMINDER, Event
from app.routing import (ALL_KINDS, Route, ai_allowed, ai_hints, delay_provider, reminder_interval,
resolve_delay, resolve_loud, resolve_targets, route_matches)
TG = Target("telegram", "1")
MX = Target("matrix", "!r:s")
DEFAULTS = [TG, MX]
def rule(position=1, **overrides):
fields = dict(id=position, name=f"r{position}", position=position, enabled=True, match_host="",
match_service="", min_state=1, kinds=ALL_KINDS, stop_on_match=False, targets=())
fields.update(overrides)
return Route(**fields)
def event(kind=KIND_PROBLEM, state=2, output="boom", host="web01", service="disk"):
return Event(ts=1, host=host, service=service, kind=kind, state=state, output=output)
def test_output_contains_is_case_insensitive_substring():
r = rule(output_contains="DEGRADED")
assert route_matches(r, event(output="pool is degraded now"))
assert not route_matches(r, event(output="pool online"))
def test_output_contains_is_not_a_regex():
r = rule(output_contains="a.c")
assert route_matches(r, event(output="xa.cx"))
assert not route_matches(r, event(output="abc"))
def test_empty_output_filter_matches_everything():
assert route_matches(rule(), event(output=""))
def test_reminder_follows_problem_rules():
only_problem = rule(kinds=(KIND_PROBLEM,), min_state=2)
assert route_matches(only_problem, event(kind=KIND_REMINDER, state=2))
assert not route_matches(only_problem, event(kind=KIND_REMINDER, state=1))
assert not route_matches(rule(kinds=(KIND_RECOVERY,)), event(kind=KIND_REMINDER))
def test_kinds_and_min_state_still_apply():
r = rule(min_state=2, kinds=(KIND_PROBLEM, KIND_FLAPPING))
assert not route_matches(r, event(state=1))
assert not route_matches(r, event(kind=KIND_RECOVERY, state=0))
assert route_matches(r, event(kind=KIND_FLAPPING, state=1))
def test_delay_without_match_uses_default():
assert resolve_delay([], "h", "s", 2, "", 15) == 15
assert resolve_delay([rule(match_host="db*", delay_minutes=1)], "web01", "s", 2, "", 15) == 15
def test_delay_rule_without_value_uses_default():
assert resolve_delay([rule()], "h", "s", 2, "", 15) == 15
def test_delay_smallest_wins_and_zero_overrides_default():
rules = [rule(1, delay_minutes=10), rule(2, delay_minutes=0), rule(3, delay_minutes=5)]
assert resolve_delay(rules, "h", "s", 2, "", 15) == 0
assert resolve_delay([rule(1, delay_minutes=10), rule(2, delay_minutes=5)], "h", "s", 2, "", 15) == 5
def test_delay_respects_service_glob_state_and_output():
r = rule(match_service="*zpool*", min_state=1, output_contains="error", delay_minutes=0)
assert resolve_delay([r], "h", "ZPool status", 1, "Some Error", 15) == 0
assert resolve_delay([r], "h", "cpu", 1, "Some Error", 15) == 15
assert resolve_delay([r], "h", "zpool", 1, "fine", 15) == 15
assert resolve_delay([rule(min_state=2, delay_minutes=0)], "h", "s", 1, "", 15) == 15
def test_delay_ignores_disabled_rules_and_honours_stop():
assert resolve_delay([rule(enabled=False, delay_minutes=0)], "h", "s", 2, "", 15) == 15
rules = [rule(1, delay_minutes=7, stop_on_match=True), rule(2, delay_minutes=0)]
assert resolve_delay(rules, "h", "s", 2, "", 15) == 7
def test_delay_provider_returns_seconds():
provider = delay_provider([rule(match_service="zpool*", delay_minutes=0)], 15)
assert provider("h", "zpool status", 1, "") == 0
assert provider("h", "cpu", 1, "") == 900
def test_ai_allowed_off_as_soon_as_one_matching_rule_disables():
assert ai_allowed([], event())
assert ai_allowed([rule(1), rule(2, match_host="db*", ai_enabled=False)], event())
assert not ai_allowed([rule(1), rule(2, ai_enabled=False)], event())
def test_resolve_loud():
assert resolve_loud([], event())
assert not resolve_loud([rule(loud=False)], event())
assert resolve_loud([rule(1, loud=False), rule(2, loud=True)], event())
assert resolve_loud([rule(1, loud=False, match_host="db*")], event())
def test_ai_hints_in_rule_order_and_only_matching():
rules = [rule(2, ai_hint="zweiter"), rule(1, ai_hint="erster"), rule(3, ai_hint="x", match_host="db*"),
rule(4, ai_hint=" ")]
assert ai_hints(rules, event()) == "erster\nzweiter"
assert ai_hints([], event()) == ""
def test_ai_hints_clipped_to_1200_characters():
rules = [rule(1, ai_hint="a" * 800), rule(2, ai_hint="b" * 800)]
text = ai_hints(rules, event())
assert len(text) == 1200 and text.startswith("a" * 800) and text.endswith("b" * 399)
def test_reminder_interval_smallest_positive_wins():
assert reminder_interval([], event()) == 0
assert reminder_interval([rule(1, reminder_hours=None), rule(2, reminder_hours=0)], event()) == 0
assert reminder_interval([rule(1, reminder_hours=8), rule(2, reminder_hours=4), rule(3)], event()) == 4
def test_use_default_targets_adds_defaults_to_explicit_targets():
other = Target("telegram", "9")
assert resolve_targets([rule(use_default_targets=True)], event(), DEFAULTS) == DEFAULTS
assert resolve_targets([rule(targets=(other,), use_default_targets=True)], event(), DEFAULTS) == [other, TG, MX]
assert resolve_targets([rule(targets=(other,))], event(), DEFAULTS) == [other]
assert resolve_targets([rule()], event(), DEFAULTS) == []
+60
View File
@@ -0,0 +1,60 @@
import logging
from app.db import Database
from app.models import KIND_FLAPPING, KIND_PROBLEM, KIND_RECOVERY
from app.routing import ZPOOL_MARKER, RouteRepository, seed_default_rules
def test_zpool_rule_is_created_with_specified_fields():
db = Database(":memory:")
assert seed_default_rules(db) is True
(rule,) = RouteRepository(db).list_all()
assert rule.name == "ZFS Zpool" and rule.enabled
assert (rule.match_host, rule.match_service, rule.min_state) == ("*", "*zpool*", 1)
assert set(rule.kinds) == {KIND_PROBLEM, KIND_RECOVERY, KIND_FLAPPING}
assert rule.use_default_targets and rule.targets == ()
assert rule.delay_minutes == 0 and rule.ai_enabled and rule.reminder_hours == 4 and rule.loud
assert "zpool status -v" in rule.ai_hint and "nicht verifiziert" in rule.ai_hint
assert len(rule.ai_hint) <= 800
def test_seed_runs_only_once():
db = Database(":memory:")
assert seed_default_rules(db) is True
assert seed_default_rules(db) is False
assert len(RouteRepository(db).list_all()) == 1
def test_deleted_rule_does_not_come_back():
db = Database(":memory:")
seed_default_rules(db)
repo = RouteRepository(db)
repo.delete(repo.list_all()[0].id)
assert seed_default_rules(db) is False
assert repo.list_all() == []
def test_seed_is_appended_after_existing_rules_and_audited():
db = Database(":memory:")
db.execute("INSERT INTO routes(name, position) VALUES ('alt', 7)")
seed_default_rules(db)
assert [r.name for r in RouteRepository(db).list_all()] == ["alt", "ZFS Zpool"]
assert db.query("SELECT user, action FROM audit")[0]["action"] == "route.seed"
assert db.query("SELECT 1 FROM app_state WHERE key=?", (ZPOOL_MARKER,))
def test_seed_failure_is_logged_and_never_raises(caplog):
class BrokenDb:
def query(self, *args):
raise RuntimeError("db kaputt")
with caplog.at_level(logging.ERROR):
assert seed_default_rules(BrokenDb()) is False
assert "Standardregel" in caplog.text
def test_failed_write_leaves_no_marker_so_next_start_retries():
db = Database(":memory:")
db.execute("DROP TABLE routes")
assert seed_default_rules(db) is False
assert db.query("SELECT 1 FROM app_state WHERE key=?", (ZPOOL_MARKER,)) == []
+6 -3
View File
@@ -140,12 +140,15 @@ def test_matrix_test_message_without_config_reports_error(client, admin):
def test_alerting_section_saves_and_validates(client, app, admin):
login_ok(client)
form = {"poll_interval": "45", "poll_min_duration": "10", "alerts_bundle_seconds": "90",
"alerts_ai_per_minute": "5"}
form = {"poll_interval": "45", "alerts_default_delay_minutes": "10", "alerts_reminder_hours": "6",
"alerts_recovery_enabled": "1", "alerts_bundle_seconds": "90", "alerts_ai_per_minute": "5"}
assert post(client, "/settings/alerting", "/settings/alerting", **form).status_code == 303
assert app.state.store.get_int("poll.interval", 30) == 45
assert app.state.store.get_int("alerts.ai_per_minute", 10) == 5
for override in ({"poll_interval": "1"}, {"poll_interval": "abc"}, {"poll_min_duration": "-3"},
assert app.state.store.get_int("alerts.default_delay_minutes", 15) == 10
assert app.state.store.get_int("alerts.reminder_hours", 0) == 6
for override in ({"poll_interval": "1"}, {"poll_interval": "abc"}, {"alerts_default_delay_minutes": "-3"},
{"alerts_default_delay_minutes": "1441"}, {"alerts_reminder_hours": "169"},
{"alerts_bundle_seconds": "99999"}, {"alerts_ai_per_minute": "1.5"}):
response = post(client, "/settings/alerting", "/settings/alerting", **{**form, **override})
assert response.status_code == 422, override
+129
View File
@@ -0,0 +1,129 @@
import time
from datetime import timedelta
from tests.test_web import ADMIN, VIEWER, admin, app, client, csrf_from, login_ok, post, users, viewer # noqa: F401
from tests.test_web_routing import enable_telegram, route_form, routes
HINT = "Geheimer Hinweis zum Pool"
def rule_form(**overrides):
data = route_form(delay_minutes="0", output_contains="degraded", ai_enabled="1", ai_hint=HINT,
reminder_hours="4", loud="1", use_default_targets="1")
data.update(overrides)
return data
def test_page_is_called_rules_and_shows_new_fields(client, admin):
login_ok(client)
page = client.get("/routing")
assert "<h1>Regeln</h1>" in page.text and ">Regeln</a>" in page.text
for label in ("Verzögerung in Minuten", "Ausgabe enthält", "KI-Analyse für diese Alarme", "KI-Hinweis",
"Erinnerung alle N Stunden", "Laut", "Standardziele aller aktivierten Backends"):
assert label in page.text, label
def test_create_stores_all_new_fields(client, app, admin):
enable_telegram(app)
login_ok(client)
assert post(client, "/routing", "/routing", **rule_form()).status_code == 303
rule = routes(app)[0]
assert rule.delay_minutes == 0 and rule.output_contains == "degraded" and rule.ai_enabled
assert rule.ai_hint == HINT and rule.reminder_hours == 4 and rule.loud and rule.use_default_targets
def test_unchecked_boxes_and_empty_numbers_mean_off_and_default(client, app, admin):
login_ok(client)
form = rule_form(delay_minutes="", reminder_hours="", output_contains="", ai_hint="")
for flag in ("ai_enabled", "loud", "use_default_targets"):
form.pop(flag)
post(client, "/routing", "/routing", **form)
rule = routes(app)[0]
assert rule.delay_minutes is None and rule.reminder_hours is None
assert not rule.ai_enabled and not rule.loud and not rule.use_default_targets
def test_edit_page_prefills_values(client, app, admin):
login_ok(client)
post(client, "/routing", "/routing", **rule_form())
page = client.get(f"/routing/{routes(app)[0].id}")
assert 'name="delay_minutes"' in page.text and 'value="0"' in page.text
assert HINT in page.text and 'value="degraded"' in page.text and 'value="4"' in page.text
def test_new_fields_are_validated(client, app, admin):
login_ok(client)
for override in ({"delay_minutes": "-1"}, {"delay_minutes": "1441"}, {"delay_minutes": "abc"},
{"delay_minutes": "1.5"}, {"reminder_hours": "169"}, {"reminder_hours": "-2"},
{"output_contains": "x" * 201}, {"ai_hint": "y" * 801}, {"ai_hint": "a\x00b"}):
response = post(client, "/routing", "/routing", **rule_form(**override))
assert response.status_code == 422, override
assert routes(app) == []
def test_boundary_values_accepted(client, app, admin):
login_ok(client)
form = rule_form(delay_minutes="1440", reminder_hours="168", output_contains="x" * 200, ai_hint="y" * 800)
assert post(client, "/routing", "/routing", **form).status_code == 303
def test_audit_has_old_and_new_values_but_never_the_hint_text(client, app, admin):
login_ok(client)
post(client, "/routing", "/routing", **rule_form())
rule = routes(app)[0]
post(client, f"/routing/{rule.id}", f"/routing/{rule.id}", **rule_form(delay_minutes="5", ai_hint="Neuer geheimer Text"))
entries = app.state.db.query("SELECT action, old_value, new_value FROM audit WHERE action LIKE 'route.%'")
update = next(e for e in entries if e["action"] == "route.update")
assert "delay=0" in update["old_value"] and "delay=5" in update["new_value"]
assert f"hint_len={len(HINT)}" in update["old_value"] and "hint_len=19" in update["new_value"]
assert all(HINT not in str(e[k]) and "Neuer geheimer" not in str(e[k]) for e in entries
for k in ("old_value", "new_value"))
def test_alerting_section_shows_new_fields_with_defaults(client, admin):
login_ok(client)
page = client.get("/settings/alerting")
assert "Standard-Verzögerung in Minuten" in page.text and 'value="15"' in page.text
assert "Recovery-Meldungen senden" in page.text and "checked" in page.text
assert "Mindestdauer" not in page.text
def test_alerting_section_saves_recovery_switch_and_reminder(client, app, admin):
login_ok(client)
form = {"poll_interval": "30", "alerts_default_delay_minutes": "0", "alerts_reminder_hours": "12",
"alerts_bundle_seconds": "60", "alerts_ai_per_minute": "10"}
assert post(client, "/settings/alerting", "/settings/alerting", **form).status_code == 303
store = app.state.store
assert store.get("alerts.recovery_enabled") == "0"
assert store.get_int("alerts.default_delay_minutes", 15) == 0
assert store.get_int("alerts.reminder_hours", 0) == 12
post(client, "/settings/alerting", "/settings/alerting", alerts_recovery_enabled="1", **form)
assert store.get("alerts.recovery_enabled") == "1"
def add_state(app, service, state=2, reported=0, age=60, downtime=0):
app.state.db.execute(
"INSERT INTO service_states(host, service, state, output, last_state_change, in_downtime, "
"reported_state) VALUES ('web01', ?, ?, 'x', ?, ?, ?)",
(service, state, int(time.time()) - age, downtime, reported))
def test_dashboard_shows_reporting_status(client, app, viewer):
add_state(app, "wartend", age=60)
add_state(app, "gemeldet", reported=2, age=7200)
add_state(app, "downtime", downtime=1, age=7200)
add_state(app, "stumm", age=7200)
app.state.runtime.mutes.create("web01", "stumm", timedelta(hours=1), "test", "admin", "ui")
login_ok(client, VIEWER)
text = client.get("/").text
assert "wartet noch 14 min" in text
assert ">gemeldet<" in text
assert "unterdrückt (Downtime)" in text and "unterdrückt (Stummschaltung)" in text
def test_dashboard_waiting_time_uses_rule_delay(client, app, admin):
login_ok(client)
post(client, "/routing", "/routing", **rule_form(delay_minutes="30", match_host="web*", output_contains="",
min_state="1"))
add_state(app, "wartend", age=60)
assert "wartet noch 29 min" in client.get("/").text