Gotify rejects a Grafana payload with 400 "Field 'message' is required", so a translation step is mandatory. This adds a small standard-library Python service: it accepts Grafana's JSON, builds a Gotify message, and posts it with an app token. Mapping: firing -> priority 8, title "[FIRING] <alertname>" resolved -> priority 3, title "[RESOLVED] <alertname>" body -> the summary annotation plus severity, namespace, pod, instance, job A multi-alert notification names the alert count instead of dropping the rest. The script ships as a ConfigMap so no image build is needed. The app token comes from a SOPS secret and belongs to Gotify application `grafana`.
111 lines
4.2 KiB
YAML
111 lines
4.2 KiB
YAML
# The bridge script is mounted as a ConfigMap so no image build is needed.
|
|
# Regenerate this block from grafana_gotify_bridge.py when the script changes.
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: grafana-gotify-bridge
|
|
namespace: notifications
|
|
data:
|
|
grafana_gotify_bridge.py: |
|
|
#!/usr/bin/env python3
|
|
"""Translate a Grafana alert webhook into a Gotify message.
|
|
|
|
Grafana sends its own payload shape:
|
|
{"receiver": "...", "status": "firing", "alerts": [{"labels": {...}, "annotations": {...}}]}
|
|
|
|
Gotify requires:
|
|
{"title": "...", "message": "...", "priority": 0..10}
|
|
|
|
Gotify answers 400 "Field 'message' is required" to a Grafana payload, so a
|
|
conversion step is mandatory. This service is that step and nothing else.
|
|
Standard library only, so it needs no image build and no dependencies.
|
|
"""
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
GOTIFY_BASE = os.environ.get("GOTIFY_URL", "http://gotify.notifications.svc.cluster.local")
|
|
GOTIFY_TOKEN = os.environ["GOTIFY_TOKEN"]
|
|
LISTEN_PORT = int(os.environ.get("PORT", "8080"))
|
|
|
|
# Firing is louder than resolved. Gotify priority runs 0 to 10.
|
|
PRIORITY = {"firing": 8, "resolved": 3}
|
|
|
|
|
|
def to_gotify(payload):
|
|
alerts = payload.get("alerts") or []
|
|
status = (payload.get("status") or "firing").lower()
|
|
priority = PRIORITY.get(status, 5)
|
|
|
|
if not alerts:
|
|
title = payload.get("title") or "Grafana notification"
|
|
return title, json.dumps(payload)[:2000], priority
|
|
|
|
first = alerts[0]
|
|
labels = first.get("labels") or {}
|
|
annotations = first.get("annotations") or {}
|
|
name = labels.get("alertname", "Grafana alert")
|
|
|
|
title = "[%s] %s" % (status.upper(), name)
|
|
lines = []
|
|
summary = annotations.get("summary") or annotations.get("description")
|
|
if summary:
|
|
lines.append(summary)
|
|
for key in ("severity", "namespace", "pod", "instance", "job"):
|
|
if labels.get(key):
|
|
lines.append("%s: %s" % (key, labels[key]))
|
|
if len(alerts) > 1:
|
|
lines.append("+%d more alert(s) in this notification" % (len(alerts) - 1))
|
|
|
|
return title, "\n".join(lines) or name, priority
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _reply(self, code, body=b"ok"):
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
self._reply(200)
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
raw = self.rfile.read(length) if length else b"{}"
|
|
try:
|
|
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
|
|
except ValueError:
|
|
payload = {}
|
|
|
|
title, message, priority = to_gotify(payload)
|
|
body = json.dumps({
|
|
"title": title[:200],
|
|
"message": message[:4000],
|
|
"priority": priority,
|
|
}).encode()
|
|
|
|
request = urllib.request.Request(
|
|
GOTIFY_BASE.rstrip("/") + "/message",
|
|
data=body,
|
|
headers={"Content-Type": "application/json", "X-Gotify-Key": GOTIFY_TOKEN},
|
|
)
|
|
try:
|
|
urllib.request.urlopen(request, timeout=10).read()
|
|
except Exception as exc: # noqa: BLE001 - log and report to Grafana
|
|
print("forward to gotify failed: %s" % exc, flush=True)
|
|
self._reply(502, b"gotify rejected the message")
|
|
return
|
|
|
|
print("forwarded: %s" % title, flush=True)
|
|
self._reply(200)
|
|
|
|
def log_message(self, fmt, *args): # keep the log to one line per delivery
|
|
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("grafana-gotify bridge listening on %d" % LISTEN_PORT, flush=True)
|
|
HTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
|