From ea33c3b7b039f603dd476fc550b35a686f94103f Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Sat, 1 Aug 2026 03:24:52 +0200 Subject: [PATCH] monitoring: Resolved hakt die Firing-Nachricht per Edit ab statt neu zu posten Ein Alarm = eine Nachricht: Firing-Event-IDs werden je Alertmanager-Fingerprint gemerkt (State-Datei, ueberlebt Restarts), Resolved ersetzt die Originalnachricht per m.replace mit durchgestrichenem Text + Haken. Ohne bekannte Zuordnung Fallback auf eigenstaendige Resolved-Nachricht. Re-Notifies desselben Alarms erzeugen keine Doppelnachrichten mehr. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj --- monitoring/alertmanager/matrix-alerts.py | 99 +++++++++++++++++++----- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/monitoring/alertmanager/matrix-alerts.py b/monitoring/alertmanager/matrix-alerts.py index 56851a8..aa3b301 100644 --- a/monitoring/alertmanager/matrix-alerts.py +++ b/monitoring/alertmanager/matrix-alerts.py @@ -1,42 +1,99 @@ #!/usr/bin/env python3 # Minimaler Alertmanager-Webhook -> Matrix-Raum (gitops#32). Gleiche Machart wie # maintenance-notify (Issue #24 im gitops-Repo): purer Stdlib-HTTP-Server, Bot-Token -# aus der Umgebung, Nachricht per Client-Server-API. Kein Framework, kein State. -import json, os, urllib.request, uuid +# aus der Umgebung, Nachricht per Client-Server-API. +# +# Ein Alarm = EINE Nachricht: beim Firing wird pro Alarm (Alertmanager-Fingerprint) +# eine Nachricht gesendet und deren Event-ID gemerkt; beim Resolved wird dieselbe +# Nachricht per m.replace-Edit durchgestrichen und abgehakt statt eine neue zu +# posten (Wunsch sorb 2026-08-01: append-only wird unuebersichtlich). Die Zuordnung +# ueberlebt Container-Restarts via State-Datei; ohne Zuordnung (z.B. nach Neubau) +# faellt Resolved auf eine eigenstaendige ✅-Nachricht zurueck. +import html +import json +import os +import urllib.parse +import urllib.request +import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer HOMESERVER = os.environ["MATRIX_HOMESERVER"] ROOM_ID = os.environ["MATRIX_ROOM_ID"] TOKEN = os.environ["MATRIX_TOKEN"] +STATE_FILE = os.environ.get("MATRIX_STATE_FILE", "/tmp/matrix-alerts-state.json") + +try: + with open(STATE_FILE) as f: + state = json.load(f) # fingerprint -> {"event_id":..., "text":...} +except Exception: + state = {} + + +def save_state(): + try: + with open(STATE_FILE, "w") as f: + json.dump(state, f) + except Exception as e: + print(f"state save failed: {e}", flush=True) + + +def matrix_put(content): + url = (f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}" + f"/send/m.room.message/{uuid.uuid4()}") + req = urllib.request.Request(url, data=json.dumps(content).encode(), method="PUT", + headers={"Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json"}) + return json.loads(urllib.request.urlopen(req, timeout=10).read()).get("event_id") + + +def send_text(text): + return matrix_put({"msgtype": "m.text", "body": text}) + + +def edit_resolved(event_id, old_text): + # m.replace-Edit: Original wird in Element in-place ersetzt (durchgestrichen + Haken) + new_body = f"✅ ~~{old_text}~~" + new_html = f"✅ {html.escape(old_text)}" + matrix_put({ + "msgtype": "m.text", + "body": f"* {new_body}", + "m.new_content": {"msgtype": "m.text", "body": new_body, + "format": "org.matrix.custom.html", "formatted_body": new_html}, + "m.relates_to": {"rel_type": "m.replace", "event_id": event_id}, + }) -def send_matrix(text): - url = f"{HOMESERVER}/_matrix/client/v3/rooms/{urllib.parse.quote(ROOM_ID)}/send/m.room.message/{uuid.uuid4()}" - body = json.dumps({"msgtype": "m.text", "body": text}).encode() - req = urllib.request.Request(url, data=body, method="PUT", headers={ - "Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}) - urllib.request.urlopen(req, timeout=10).read() class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/alert": self.send_response(404); self.end_headers(); return data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) - lines = [] - for a in data.get("alerts", []): - icon = "\U0001F534" if a.get("status") == "firing" else "✅" - name = a.get("labels", {}).get("alertname", "?") - summary = a.get("annotations", {}).get("summary", "") - lines.append(f"{icon} [{a.get('status')}] {name}: {summary}") - if lines: - try: - send_matrix("\n".join(lines)) - except Exception as e: - self.log_message("matrix send failed: %s", e) - self.send_response(502); self.end_headers(); return + try: + for a in data.get("alerts", []): + name = a.get("labels", {}).get("alertname", "?") + summary = a.get("annotations", {}).get("summary", "") + fp = a.get("fingerprint", "") + if a.get("status") == "firing": + text = f"\U0001F534 [firing] {name}: {summary}" + if fp in state: # re-notify desselben Alarms -> keine Doppelnachricht + continue + event_id = send_text(text) + if fp and event_id: + state[fp] = {"event_id": event_id, "text": text} + else: + known = state.pop(fp, None) + if known: + edit_resolved(known["event_id"], known["text"]) + else: + send_text(f"✅ [resolved] {name}: {summary}") + save_state() + except Exception as e: + self.log_message("matrix send failed: %s", e) + self.send_response(502); self.end_headers(); return self.send_response(200); self.end_headers() def log_message(self, fmt, *args): print(fmt % args, flush=True) -import urllib.parse + ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()