render alert from jinja2 templates

This commit is contained in:
HgO 2022-07-28 17:39:47 +02:00
parent bbcc0cc427
commit 6781bc82fa
15 changed files with 155 additions and 50 deletions

View file

@ -1,7 +1,15 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Dict from typing import Dict, Optional
from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
FileSystemLoader,
PackageLoader,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,6 +32,7 @@ class Alert:
self.labels = labels self.labels = labels
self.annotations = annotations self.annotations = annotations
self.description = annotations["description"]
if self.firing: if self.firing:
self.status = self.labels["severity"] self.status = self.labels["severity"]
@ -48,20 +57,20 @@ class Alert:
def color(self) -> str: def color(self) -> str:
return self.COLORS[self.status] return self.COLORS[self.status]
def plaintext(self) -> str:
alertname = self.labels["alertname"]
description = self.annotations["description"]
return f"[{self.emoji} {self.status.upper()}] {alertname}: {description}"
def html(self) -> str: class AlertRenderer:
alertname = self.labels["alertname"] def __init__(self, template_dir: Optional[str] = None) -> None:
loader: BaseLoader = PackageLoader("matrix_alertbot", "resources/templates")
if template_dir is not None:
loader = ChoiceLoader([FileSystemLoader(template_dir), loader])
env = Environment(loader=loader, autoescape=True)
job = self.labels.get("job", "") self.html_template = env.get_template("alert.html.j2")
if job: self.text_template = env.get_template("alert.txt.j2")
job = f"({job})"
description = self.annotations["description"] def render(self, alert: Alert, html: bool = True) -> str:
return ( if html:
f"<font color='#{self.color}'><b>[{self.emoji} {self.status.upper()}]</b></font> " template = self.html_template
f"<a href='{self.url}'>{alertname}</a> {job}<br/>{description}" else:
) template = self.text_template
return template.render(vars(alert), color=alert.color, emoji=alert.emoji)

View file

@ -21,9 +21,6 @@ from matrix_alertbot.config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"}
class Callbacks: class Callbacks:
def __init__( def __init__(
self, self,
@ -176,7 +173,7 @@ class Callbacks:
reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key") reaction = event.source.get("content", {}).get("m.relates_to", {}).get("key")
logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.") logger.debug(f"Got reaction {reaction} to {room.room_id} from {event.sender}.")
if reaction not in REACTIONS: if reaction not in self.config.allowed_reactions:
logger.warning(f"Uknown duration reaction {reaction}") logger.warning(f"Uknown duration reaction {reaction}")
return return

View file

@ -104,7 +104,9 @@ class AckAlertCommand(BaseAlertCommand):
) )
return return
elif duration_seconds < 0: elif duration_seconds < 0:
logger.error(f"Unable to create silence: Duration must be positive, got '{duration}'") logger.error(
f"Unable to create silence: Duration must be positive, got '{duration}'"
)
await send_text_to_room( await send_text_to_room(
self.client, self.client,
self.room.room_id, self.room.room_id,

View file

@ -19,6 +19,9 @@ logging.getLogger("peewee").setLevel(
) # Prevent debug messages from peewee lib ) # Prevent debug messages from peewee lib
DEFAULT_REACTIONS = {"🤫", "😶", "🤐", "🙊", "🔇", "🔕"}
class Config: class Config:
"""Creates a Config object from a YAML-encoded config file from a given filepath""" """Creates a Config object from a YAML-encoded config file from a given filepath"""
@ -75,6 +78,9 @@ class Config:
f"storage.path '{self.store_dir}' is not a directory" f"storage.path '{self.store_dir}' is not a directory"
) )
# Template setup
self.template_dir: str = self._get_cfg(["template", "path"], required=False)
# Cache setup # Cache setup
self.cache_dir: str = self._get_cfg(["cache", "path"], required=True) self.cache_dir: str = self._get_cfg(["cache", "path"], required=True)
expire_time: str = self._get_cfg(["cache", "expire_time"], default="1w") expire_time: str = self._get_cfg(["cache", "expire_time"], default="1w")
@ -105,6 +111,9 @@ class Config:
self.allowed_rooms: list = self._get_cfg( self.allowed_rooms: list = self._get_cfg(
["matrix", "allowed_rooms"], required=True ["matrix", "allowed_rooms"], required=True
) )
self.allowed_reactions = set(
self._get_cfg(["matrix", "allowed_reactions"], default=DEFAULT_REACTIONS)
)
self.address: str = self._get_cfg(["webhook", "address"], required=False) self.address: str = self._get_cfg(["webhook", "address"], required=False)
self.port: int = self._get_cfg(["webhook", "port"], required=False) self.port: int = self._get_cfg(["webhook", "port"], required=False)

View file

@ -0,0 +1,5 @@
<font color="#{{ color }}">
<b>[{{ emoji }} {{ status | upper }}]</b>
</font> <a href="{{ url }}">{{ labels.alertname }}</a>
{% if 'job' in labels %} ({{ labels.job }}){% endif %}<br/>
{{ description }}

View file

@ -0,0 +1 @@
[{{ emoji }} {{ status | upper }}] {{ labels.alertname }}: {{ description }}

View file

@ -9,7 +9,7 @@ from aiohttp_prometheus_exporter.middleware import prometheus_middleware_factory
from diskcache import Cache from diskcache import Cache
from nio import AsyncClient, LocalProtocolError from nio import AsyncClient, LocalProtocolError
from matrix_alertbot.alert import Alert from matrix_alertbot.alert import Alert, AlertRenderer
from matrix_alertbot.chat_functions import send_text_to_room from matrix_alertbot.chat_functions import send_text_to_room
from matrix_alertbot.config import Config from matrix_alertbot.config import Config
@ -31,10 +31,13 @@ async def create_alerts(request: web_request.Request) -> web.Response:
client: AsyncClient = request.app["client"] client: AsyncClient = request.app["client"]
config: Config = request.app["config"] config: Config = request.app["config"]
cache: Cache = request.app["cache"] cache: Cache = request.app["cache"]
alert_renderer: AlertRenderer = request.app["alert_renderer"]
if room_id not in config.allowed_rooms: if room_id not in config.allowed_rooms:
logger.error("Cannot send alerts to room ID {room_id}.") logger.error("Cannot send alerts to room ID {room_id}.")
return web.Response(status=401, body=f"Cannot send alerts to room ID {room_id}.") return web.Response(
status=401, body=f"Cannot send alerts to room ID {room_id}."
)
if "alerts" not in data: if "alerts" not in data:
logger.error("Received data without 'alerts' key") logger.error("Received data without 'alerts' key")
@ -61,8 +64,8 @@ async def create_alerts(request: web_request.Request) -> web.Response:
logger.error(f"Cannot parse alert dict: {e}") logger.error(f"Cannot parse alert dict: {e}")
return web.Response(status=400, body=f"Invalid alert: {alert}.") return web.Response(status=400, body=f"Invalid alert: {alert}.")
plaintext = alert.plaintext() plaintext = alert_renderer.render(alert, html=False)
html = alert.html() html = alert_renderer.render(alert, html=True)
try: try:
event = await send_text_to_room( event = await send_text_to_room(
@ -91,6 +94,7 @@ class Webhook:
self.app["client"] = client self.app["client"] = client
self.app["config"] = config self.app["config"] = config
self.app["cache"] = cache self.app["cache"] = cache
self.app["alert_renderer"] = AlertRenderer(config.template_dir)
self.app.add_routes(routes) self.app.add_routes(routes)
prometheus_registry = prometheus_client.CollectorRegistry(auto_describe=True) prometheus_registry = prometheus_client.CollectorRegistry(auto_describe=True)
@ -112,10 +116,10 @@ class Webhook:
site: web.BaseSite site: web.BaseSite
if self.address and self.port: if self.address and self.port:
site = web.TCPSite(self.runner, self.address, self.port) site = web.TCPSite(self.runner, self.address, self.port)
logger.info(f"Listenning on {self.address}:{self.port}") logger.info(f"Listening on {self.address}:{self.port}")
elif self.socket: elif self.socket:
site = web.UnixSite(self.runner, self.socket) site = web.UnixSite(self.runner, self.socket)
logger.info(f"Listenning on unix://{self.socket}") logger.info(f"Listening on unix://{self.socket}")
await site.start() await site.start()

View file

@ -29,6 +29,7 @@ setup(
"aiohttp-prometheus-exporter>=0.2.4", "aiohttp-prometheus-exporter>=0.2.4",
"aiotools>=1.5.9", "aiotools>=1.5.9",
"diskcache>=5.4.0", "diskcache>=5.4.0",
"jinja2>=3.1.2",
"matrix-nio>=0.19.0", "matrix-nio>=0.19.0",
"Markdown>=3.3.7", "Markdown>=3.3.7",
"pytimeparse2>=1.4.0", "pytimeparse2>=1.4.0",

View file

@ -22,6 +22,7 @@ matrix:
device_name: fake_device_name device_name: fake_device_name
allowed_rooms: allowed_rooms:
- "!abcdefgh:matrix.example.com" - "!abcdefgh:matrix.example.com"
allowed_reactions: [🤫, 😶, 🤐]
webhook: webhook:
socket: matrix-alertbot.socket socket: matrix-alertbot.socket
@ -38,6 +39,9 @@ storage:
# containing encryption keys, sync tokens, etc. # containing encryption keys, sync tokens, etc.
path: "data/store" path: "data/store"
template:
path: "data/templates"
# Logging setup # Logging setup
logging: logging:
# Logging level # Logging level

View file

@ -0,0 +1 @@
<b>hello world</b>

View file

@ -0,0 +1 @@
hello world

View file

@ -1,7 +1,10 @@
import os
import unittest import unittest
from typing import Dict from typing import Dict
from matrix_alertbot.alert import Alert from matrix_alertbot.alert import Alert, AlertRenderer
TESTS_DIR = os.path.dirname(__file__)
class AlertTestCase(unittest.TestCase): class AlertTestCase(unittest.TestCase):
@ -40,62 +43,121 @@ class AlertTestCase(unittest.TestCase):
self.assertEqual("resolved", alert.status) self.assertEqual("resolved", alert.status)
self.assertFalse(alert.firing) self.assertFalse(alert.firing)
def test_display_firing_critical_alert(self) -> None:
class AlertRendererTestCase(unittest.TestCase):
def setUp(self) -> None:
self.alert_dict: Dict = {
"fingerprint": "fingerprint1",
"generatorURL": "http://example.com",
"status": "unknown",
"labels": {"alertname": "alert1", "severity": "critical", "job": "job1"},
"annotations": {"description": "some description"},
}
self.renderer = AlertRenderer()
def test_render_firing_critical_alert(self) -> None:
self.alert_dict["status"] = "firing" self.alert_dict["status"] = "firing"
alert = Alert.from_dict(self.alert_dict) alert = Alert.from_dict(self.alert_dict)
alert.labels["severity"] = "critical" alert.labels["severity"] = "critical"
html = alert.html() html = self.renderer.render(alert, html=True)
self.assertEqual( self.assertEqual(
"<font color='#dc3545'><b>[🔥 CRITICAL]</b></font> " '<font color="#dc3545">\n <b>[🔥 CRITICAL]</b>\n</font> '
"<a href='http://example.com'>alert1</a> (job1)<br/>" '<a href="http://example.com">alert1</a>\n (job1)<br/>\n'
"some description", "some description",
html, html,
) )
plaintext = alert.plaintext() plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[🔥 CRITICAL] alert1: some description", plaintext) self.assertEqual("[🔥 CRITICAL] alert1: some description", plaintext)
def test_display_firing_warning_alert(self) -> None: def test_render_firing_warning_alert(self) -> None:
self.alert_dict["status"] = "firing" self.alert_dict["status"] = "firing"
self.alert_dict["labels"]["severity"] = "warning" self.alert_dict["labels"]["severity"] = "warning"
alert = Alert.from_dict(self.alert_dict) alert = Alert.from_dict(self.alert_dict)
html = alert.html() html = self.renderer.render(alert, html=True)
self.assertEqual( self.assertEqual(
"<font color='#ffc107'><b>[⚠️ WARNING]</b></font> " '<font color="#ffc107">\n <b>[⚠️ WARNING]</b>\n</font> '
"<a href='http://example.com'>alert1</a> (job1)<br/>" '<a href="http://example.com">alert1</a>\n (job1)<br/>\n'
"some description", "some description",
html, html,
) )
plaintext = alert.plaintext() plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[⚠️ WARNING] alert1: some description", plaintext) self.assertEqual("[⚠️ WARNING] alert1: some description", plaintext)
def test_display_firing_unknown_alert(self) -> None: def test_render_firing_unknown_alert(self) -> None:
self.alert_dict["status"] = "firing" self.alert_dict["status"] = "firing"
self.alert_dict["labels"]["severity"] = "unknown" self.alert_dict["labels"]["severity"] = "unknown"
alert = Alert.from_dict(self.alert_dict) alert = Alert.from_dict(self.alert_dict)
with self.assertRaisesRegex(KeyError, "unknown"): with self.assertRaisesRegex(KeyError, "unknown"):
alert.html() self.renderer.render(alert, html=True)
with self.assertRaisesRegex(KeyError, "unknown"): with self.assertRaisesRegex(KeyError, "unknown"):
alert.plaintext() self.renderer.render(alert, html=False)
def test_display_resolved_alert(self) -> None: def test_render_resolved_alert(self) -> None:
self.alert_dict["status"] = "resolved" self.alert_dict["status"] = "resolved"
alert = Alert.from_dict(self.alert_dict) alert = Alert.from_dict(self.alert_dict)
html = alert.html() html = self.renderer.render(alert, html=True)
self.assertEqual( self.assertEqual(
"<font color='#33cc33'><b>[🥦 RESOLVED]</b></font> " '<font color="#33cc33">\n <b>[🥦 RESOLVED]</b>\n</font> '
"<a href='http://example.com'>alert1</a> (job1)<br/>" '<a href="http://example.com">alert1</a>\n (job1)<br/>\n'
"some description", "some description",
html, html,
) )
plaintext = alert.plaintext() plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)
def test_render_resolved_alert_without_job(self) -> None:
self.alert_dict["status"] = "resolved"
del self.alert_dict["labels"]["job"]
alert = Alert.from_dict(self.alert_dict)
html = self.renderer.render(alert, html=True)
self.assertEqual(
'<font color="#33cc33">\n <b>[🥦 RESOLVED]</b>\n</font> '
'<a href="http://example.com">alert1</a>\n<br/>\n'
"some description",
html,
)
plaintext = self.renderer.render(alert, html=False)
self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)
def test_render_with_existing_filesystem_template(self) -> None:
alert = Alert.from_dict(self.alert_dict)
template_dir = os.path.join(TESTS_DIR, "resources/templates")
renderer = AlertRenderer(template_dir)
html = renderer.render(alert, html=True)
self.assertEqual(
"<b>hello world</b>",
html,
)
plaintext = renderer.render(alert, html=False)
self.assertEqual("hello world", plaintext)
def test_render_with_inexistent_filesystem_template(self) -> None:
self.alert_dict["status"] = "resolved"
alert = Alert.from_dict(self.alert_dict)
renderer = AlertRenderer(TESTS_DIR)
html = renderer.render(alert, html=True)
self.assertEqual(
'<font color="#33cc33">\n <b>[🥦 RESOLVED]</b>\n</font> '
'<a href="http://example.com">alert1</a>\n (job1)<br/>\n'
"some description",
html,
)
plaintext = renderer.render(alert, html=False)
self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext) self.assertEqual("[🥦 RESOLVED] alert1: some description", plaintext)

View file

@ -31,6 +31,7 @@ class CallbacksTestCase(unittest.IsolatedAsyncioTestCase):
# We don't spec config, as it doesn't currently have well defined attributes # We don't spec config, as it doesn't currently have well defined attributes
self.fake_config = Mock() self.fake_config = Mock()
self.fake_config.allowed_rooms = [self.fake_room.room_id] self.fake_config.allowed_rooms = [self.fake_room.room_id]
self.fake_config.allowed_reactions = ["🤫"]
self.fake_config.command_prefix = "!alert " self.fake_config.command_prefix = "!alert "
self.callbacks = Callbacks( self.callbacks = Callbacks(

View file

@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
import yaml import yaml
from matrix_alertbot.config import Config from matrix_alertbot.config import DEFAULT_REACTIONS, Config
from matrix_alertbot.errors import ( from matrix_alertbot.errors import (
InvalidConfigError, InvalidConfigError,
ParseConfigError, ParseConfigError,
@ -58,6 +58,7 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("matrix-alertbot", config.device_name) self.assertEqual("matrix-alertbot", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url) self.assertEqual("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms) self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
self.assertEqual(DEFAULT_REACTIONS, config.allowed_reactions)
self.assertEqual("0.0.0.0", config.address) self.assertEqual("0.0.0.0", config.address)
self.assertEqual(8080, config.port) self.assertEqual(8080, config.port)
@ -71,6 +72,8 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("data/store", config.store_dir) self.assertEqual("data/store", config.store_dir)
self.assertIsNone(config.template_dir)
self.assertEqual("!alert ", config.command_prefix) self.assertEqual("!alert ", config.command_prefix)
@patch("os.path.isdir") @patch("os.path.isdir")
@ -96,6 +99,7 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("fake_device_name", config.device_name) self.assertEqual("fake_device_name", config.device_name)
self.assertEqual("https://matrix.example.com", config.homeserver_url) self.assertEqual("https://matrix.example.com", config.homeserver_url)
self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms) self.assertEqual(["!abcdefgh:matrix.example.com"], config.allowed_rooms)
self.assertEqual({"🤫", "😶", "🤐"}, config.allowed_reactions)
self.assertIsNone(config.address) self.assertIsNone(config.address)
self.assertIsNone(config.port) self.assertIsNone(config.port)
@ -109,6 +113,8 @@ class ConfigTestCase(unittest.TestCase):
self.assertEqual("data/store", config.store_dir) self.assertEqual("data/store", config.store_dir)
self.assertEqual("data/templates", config.template_dir)
self.assertEqual("!alert ", config.command_prefix) self.assertEqual("!alert ", config.command_prefix)
def test_read_config_raise_config_error(self) -> None: def test_read_config_raise_config_error(self) -> None:

View file

@ -32,6 +32,7 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_config.socket = "webhook.sock" self.fake_config.socket = "webhook.sock"
self.fake_config.allowed_rooms = [self.fake_room_id] self.fake_config.allowed_rooms = [self.fake_room_id]
self.fake_config.cache_expire_time = 0 self.fake_config.cache_expire_time = 0
self.fake_config.template_dir = None
self.fake_alerts = { self.fake_alerts = {
"alerts": [ "alerts": [
@ -77,8 +78,8 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_client, self.fake_client,
self.fake_room_id, self.fake_room_id,
"[🔥 CRITICAL] alert1: some description1", "[🔥 CRITICAL] alert1: some description1",
"<font color='#dc3545'><b>[🔥 CRITICAL]</b></font> " '<font color="#dc3545">\n <b>[🔥 CRITICAL]</b>\n</font> '
"<a href='http://example.com/alert1'>alert1</a> (job1)<br/>" '<a href="http://example.com/alert1">alert1</a>\n (job1)<br/>\n'
"some description1", "some description1",
notice=False, notice=False,
), ),
@ -86,8 +87,8 @@ class WebhookApplicationTestCase(aiohttp.test_utils.AioHTTPTestCase):
self.fake_client, self.fake_client,
self.fake_room_id, self.fake_room_id,
"[🥦 RESOLVED] alert2: some description2", "[🥦 RESOLVED] alert2: some description2",
"<font color='#33cc33'><b>[🥦 RESOLVED]</b></font> " '<font color="#33cc33">\n <b>[🥦 RESOLVED]</b>\n</font> '
"<a href='http://example.com/alert2'>alert2</a> (job2)<br/>" '<a href="http://example.com/alert2">alert2</a>\n (job2)<br/>\n'
"some description2", "some description2",
notice=False, notice=False,
), ),
@ -212,6 +213,7 @@ class WebhookServerTestCase(unittest.IsolatedAsyncioTestCase):
self.fake_config.address = "localhost" self.fake_config.address = "localhost"
self.fake_config.socket = "webhook.sock" self.fake_config.socket = "webhook.sock"
self.fake_config.cache_expire_time = 0 self.fake_config.cache_expire_time = 0
self.fake_config.template_dir = None
@patch.object(matrix_alertbot.webhook.web, "TCPSite", autospec=True) @patch.object(matrix_alertbot.webhook.web, "TCPSite", autospec=True)
async def test_webhook_start_address_port(self, fake_tcp_site: Mock) -> None: async def test_webhook_start_address_port(self, fake_tcp_site: Mock) -> None: