diff --git a/app.py b/app.py index d0d4d1bc37976b0e525dcd499f3fd380497fddc4..c5eff58bf897466c21269a0efaebde4ed3ea8dec 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,12 @@ import requests -from flask import Flask, request, make_response -from config import url +from flask import Flask, request, make_response, render_template + +from config import url, hookshot_params, flask_params, msg_templates, msg_template_default +from validate import grafana_validate_incoming + + +app = Flask(__name__, **flask_params) -app = Flask(__name__) @app.route("/webhook/slack/<hook>", methods=['POST']) def slack(hook): @@ -65,35 +69,43 @@ def slack(hook): @app.route("/webhook/grafana/<hook>", methods=['POST', 'PUT']) def grafana(hook): - plain = '' - html = '' + """ + see https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier/ + todo: + - handle query params to select different templates + - handle empty -> default + """ + + args = request.args + if "template" in args.keys(): + template_type = args.get("template") + if template_type not in msg_templates: + template_type = msg_template_default + else: + template_type = msg_template_default + + if "version" in args.keys(): + version = args.get("version") + else: + version = "v2" + + print(args) + print(f"template: {template_type}, version: {version}") + incoming = request.json print('Got incoming /grafana hook: ' + str(incoming)) - title = str(incoming.get('title', '')) - rule_url = str(incoming.get('ruleUrl', '')) - rule_name = str(incoming.get('ruleName', '')) - message = str(incoming.get('message', '')) - state = str(incoming.get('state', '')) - eval_matches = incoming.get('evalMatches', []) - - if title and rule_url and rule_name: - plain += title + ' ' + rule_url + ': ' + rule_name + ' (' + state + ')\n' - html += '<b><a href="' + rule_url + '">' + title + '</a></b>: ' + rule_name + ' (' + state + ')<br/>\n' + if not isinstance(incoming, dict): + incoming = dict() - if message: - plain += message + '\n' - html += message + '<br/>\n' + grafana_validate_incoming(incoming) - for eval_match in eval_matches: - metric = str(eval_match.get('metric', '')) - value = str(eval_match.get('value', '')) - if metric and value: - plain += metric + ': ' + value + '\n' - html += '<b>' + metric + '</b>: ' + value + '<br/>\n' + t = lambda fmt: f"{template_type}.{fmt}.jinja" + plain = render_template(t("txt"), **incoming) + html = render_template(t("html"), **incoming) if plain and html: - json = {'text':plain,'html':html} + json = {'text':plain,'html':html, **hookshot_params} print('Sending hookshot: ' + str(json)) r = requests.post(url + hook, json=json) else: @@ -102,5 +114,6 @@ def grafana(hook): return {"ok":True} + if __name__ == "__main__": - app.run(port=9080, debug=True) + app.run(host="0.0.0.0", port=9080, debug=True) diff --git a/config.py b/config.py index 3b638abf23e1969d363ec44c5afb0c72705cdf67..000562e4b7a57913ebfd65c3e1d87fc397aadb80 100644 --- a/config.py +++ b/config.py @@ -1 +1,6 @@ url = 'https://hookshot.mbot.ethz.ch/webhook/' +flask_params = {"template_folder": "template"} +hookshot_params = {"version": "v2", "msgtype": "m.notice"} + +msg_templates = ("default", "concise", "ultra_concise") +msg_template_default = "ultra_concise" diff --git a/template/concise.html.jinja b/template/concise.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..3c423ed8ebf967ef599cdf65846f59ae51b5beef --- /dev/null +++ b/template/concise.html.jinja @@ -0,0 +1,16 @@ +{% if alerts | length %} +{% for alert in alerts %} +<b><a href="{{ alert.generatorURL }}">[{{ alert.status }}] {{ alert.labels.alertname }}</a>:</b> +{% if alert.labels %} +{% for key, value in alert.labels.items() %} +{{ key }}={{ value }} {{ ", " if not loop.last else " " }} +{% endfor %} +{% endif %} +(<a href="{{ alert.silenceURL }}">Silence</a>)<br> +{% if alert.valuesForJinja %} +{% for key, value in alert.valuesForJinja.items() %} +<b>{{ key }}:</b> {{ value }}<br> +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} diff --git a/template/concise.txt.jinja b/template/concise.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..1451d1460cd1d05a12e29b13ceae29f2b5a3917a --- /dev/null +++ b/template/concise.txt.jinja @@ -0,0 +1,8 @@ +{% if alerts | length %}{% for alert in alerts %} +[{{ alert.status }}] {{ alert.labels.alertname }}: +Alert: {{ alert.generatorURL }} +Silence: {{ alert.silenceURL }} +{% if alert.labels %}{% for key, value in alert.labels.items() %}{{ key }}={{ value }}, {% endfor %}{% endif %} +{% if alert.valuesForJinja %}{% for key, value in alert.valuesForJinja.items() %}{{ key }}: {{ value }}, {% endfor %}{% endif %} +___ +{% endfor %}{% endif %} diff --git a/template/default.html.jinja b/template/default.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..0d2fe3945c74680df5479d9fb5029717fc1edeb2 --- /dev/null +++ b/template/default.html.jinja @@ -0,0 +1,34 @@ +<b>{{ status | upper }}:{{ noFiring }} ({{ commonLabels.values() | join(" ") }})</b> +<p><a href="{{ externalUrl }}?orgId={{ orgId }}">Grafana Instance</a></p> +{% if alerts | length %}{% for alert in alerts %} +<p><b>{{ alert.labels.alertname }}</b> (Status: {{ alert.status }}): + <a href="{{ alert.generatorURL }}">View</a>, <a href="{{ alert.silenceURL }}">Silence</a> +</p> + +{% if alert.valuesForJinja %} +<b>Values</b> +<table> + {% for key, value in alert.valuesForJinja.items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} + +{% if alert.labels %} +<b>Labels</b> +<table> + {% for key, value in alert.labels.items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} +<p><i>(StartsAt: {{alert.startsAtParsed }}{% if alert.endsAtParsed %}, EndsAt: {{alert.endsAtParsed }}{% endif %})</i> +</p> +<br> +{% endfor %}{% endif %} \ No newline at end of file diff --git a/template/default.txt.jinja b/template/default.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..c5ae8c593278be08284f2ef76d33ba5698e7be5d --- /dev/null +++ b/template/default.txt.jinja @@ -0,0 +1,26 @@ +{{ status | upper }}:{{ noFiring }} ({{ commonLabels.values() | join(" ") }}) +Grafana: {{ externalUrl }}?orgId={{ orgId }} + +{% if alerts | length %} +{% for alert in alerts %} +--- +Name: {{ alert.labels.alertname }} +Status: {{ alert.status }} +View alert: {{ alert.generatorURL }} +Silence alert: {{ alert.silenceURL }} + +{% if alert.Values %} +Values: +{% for key, value in alert.Values.items() %} +{{ key }}: {{ value }} +{% endfor %} +{% endif %} + +{% if alert.labels %} +Labels +{% for key, value in alert.labels.items() %} +{{ key }}: {{ value }} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} diff --git a/template/slack.html.jinja b/template/slack.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..bfe10b05cb95ba0de489a1ef12d7e64cf06d003f --- /dev/null +++ b/template/slack.html.jinja @@ -0,0 +1,25 @@ +{% if color %} +<font color="{{ color}}"> +{% endif %} + +{% if title and title_link %} +<b><a href="{{ title_link }}">{{ title}}</a></b><br/> +{% elif title %} +<b>{{ title}}</b><br/> +{% endif %} + +{% if text %} +{{ text }}<br /> +{% endif %} + +{% for field in fields %} +<b>{{ field.title }}:</b> {{ value}}<br/> +{% endfor %} + +{% if footer %} +{{ footer}}<br/> +{% endif %} + +{% if color%} +</font> +{% endif %} \ No newline at end of file diff --git a/template/slack.txt.jinja b/template/slack.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..93a2b4bf37a96c5ff03cdf91312f8e81dab6d61f --- /dev/null +++ b/template/slack.txt.jinja @@ -0,0 +1,11 @@ +{% if title and title_link %} +{{ title }} {{ title_link }} +{% elif title %}{{ title }}{% endif %} + +{% if text %}{{ text }}{% endif %} + +{% for field in fields %} +{{ field.title }}: {{ value}} +{% endfor %} + +{{ footer}} diff --git a/template/ultra_concise.html.jinja b/template/ultra_concise.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..fa2d51d5a798dabc6432d9248a2fbe65812f556d --- /dev/null +++ b/template/ultra_concise.html.jinja @@ -0,0 +1,18 @@ +{% if alerts | length %} +{% for alert in alerts %} +{{ "<br>" if not loop.first }} +<b><a href="{{ alert.generatorURL }}">[{{ alert.status }}] {{ alert.labels.alertname }}</a></b> +(<a href="{{ alert.silenceURL }}">Silence</a>)<b>:</b> +{% if alert.labels %} +{% for key, value in alert.labels.items() %} +{{ key }}={{ value }} {{- ", " if not loop.last else " " }} +{% endfor %} +{% endif %} +<br> +{% if alert.valuesForJinja %} +{% for key, value in alert.valuesForJinja.items() %} +<b>{{ key }}:</b> {{ value }}{{ ", " if not loop.last else " " }} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} diff --git a/template/ultra_concise.txt.jinja b/template/ultra_concise.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..1451d1460cd1d05a12e29b13ceae29f2b5a3917a --- /dev/null +++ b/template/ultra_concise.txt.jinja @@ -0,0 +1,8 @@ +{% if alerts | length %}{% for alert in alerts %} +[{{ alert.status }}] {{ alert.labels.alertname }}: +Alert: {{ alert.generatorURL }} +Silence: {{ alert.silenceURL }} +{% if alert.labels %}{% for key, value in alert.labels.items() %}{{ key }}={{ value }}, {% endfor %}{% endif %} +{% if alert.valuesForJinja %}{% for key, value in alert.valuesForJinja.items() %}{{ key }}: {{ value }}, {% endfor %}{% endif %} +___ +{% endfor %}{% endif %} diff --git a/validate.py b/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..c7fe8f2822a08187ddcbd5f7e0ff52dd0493f109 --- /dev/null +++ b/validate.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Dict + +# https://grafana.com/docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/ + + +def validate_str_entry(d: Dict, k: str): + """Empty string if not exists""" + d[k] = d.get(k, "") + + +def validate_number_entry(d: Dict, k: str): + """0 if not exists""" + d[k] = d.get(k, 0) + + +def validate_dict_entry(d: Dict, k: str): + """Empty dict if not exists""" + entry = d.get(k, None) + if not isinstance(entry, dict): + entry = dict() + d[k] = entry + + +def grafana_validate_incoming(incoming: Dict): + """ + Makes sure the necessary keys are present in the incoming dict so it can be parsed by jinja + """ + string_keys = ("receiver", "status", "externalUrl", "version", "groupKey") + for key in string_keys: + validate_str_entry(incoming, key) + + validate_number_entry(incoming, "orgId") + validate_number_entry(incoming, "truncatedAlerts") + + validate_dict_entry(incoming, "groupLabels") + validate_dict_entry(incoming, "commonLabels") + validate_dict_entry(incoming, "commonAnnotations") + + incoming["alerts"] = alerts = incoming.get("alerts", []) + + for alert in alerts: # Handle non dict values? + grafana_validate_alert(alert) + + l = lambda a: a.get("status", "") == "firing" + incoming["no_firing"] = len(tuple(filter(l, alerts))) + + +def grafana_validate_alert(alert: Dict): + + string_keys = ( + "status", + "startsAt", + "endsAt", + "generatorURL", + "fingerprint", + "silenceURL", + "imageURL", + ) + for key in string_keys: + validate_str_entry(alert, key) + + validate_dict_entry(alert, "labels") + validate_dict_entry(alert, "annotations") + validate_dict_entry(alert, "values") + + # fixes "values" being shadowed in jinja + alert["valuesForJinja"] = alert.get("values", None) + + alert["startsAtParsed"] = format_dt(alert.get("startsAt", "")) + alert["endsAtParsed"] = format_dt(alert.get("endsAt", "")) + + +def format_dt(dt_str: str) -> str: + """ + Returns empty string if error or is year 1 e.g. zero value of golang time.Time + see https://pkg.go.dev/time#Time + """ + try: + dt = datetime.fromisoformat(dt_str) + if dt.year < 2: + return "" + return dt.strftime("%Y-%m-%d %H:%M:%S %z") + except ValueError: + return ""